您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 /settings/gadgets 页面提供分页、筛选、标签、搜索、AI摘要切换。
// ==UserScript== // @name Bangumi 组件分类 (本地版) // @namespace https://bgm.tv/ // @version 1.0.4 // @description 在 /settings/gadgets 页面提供分页、筛选、标签、搜索、AI摘要切换。 // @author wataame // @match https://bgm.tv/settings/gadgets* // @match http://bgm.tv/settings/gadgets* // @match https://bangumi.tv/settings/gadgets* // @match http://bangumi.tv/settings/gadgets* // @match http://chii.in/settings/gadgets* // @match https://chii.in/settings/gadgets* // @connect gadgets.ry.mk // @connect gist.githubusercontent.com // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @license MIT // ==/UserScript== (() => { 'use strict'; /* ---------- 样式 ---------- */ GM_addStyle(` /* —— 亮色模式 (原始样式) —— */ /* ——侧栏—— */ #gadgets-filter-app .subtitle { font-size: 1.2em; font-weight: bold; color: #f09199; border-bottom: 1px solid #f09199; padding-bottom: 3px; margin-bottom: 10px; } #gadgets-filter-app .grouped { list-style: none; padding: 0; margin: 0 0 1em; } #gadgets-filter-app .grouped li { margin-bottom: 10px; } #gadgets-filter-app .inputtext, #gadgets-filter-app .select { border: 1px solid #AAA; padding: 5px; width: 100%; box-sizing: border-box; border-radius: 5px; background: #FFF; color: #000; } #gadgets-filter-app .btn { display: block; width: 100%; padding: 8px 0; text-align: center; border-radius: 5px; cursor: pointer; border: 1px solid; font-weight: bold; } #gadgets-filter-app .btn_search { background: #f09199; border-color: #e38aa1; color: #fff; } #gadgets-filter-app .btn_search:disabled { background: #CCC; border-color: #BBB; cursor: not-allowed; } #gadgets-filter-app .btn_reset { background: #f2f2f2; border-color: #ccc; color: #555; margin-top: 10px; } #gadgets-filter-app .filter-info { color: #999; margin-bottom: 10px; text-align: center; } #gadgets-filter-app #gadget-toggle-summary { padding: 4px 0; font-size: 0.9em; font-weight: normal; margin-top: 0; } /* ——侧栏标签列表—— */ .gadget-tag-list-container { background: #F9F9F9; border: 1px solid #E0E0E0; padding: 8px; border-radius: 5px; max-height: 160px; overflow-y: auto; line-height: 1.8; } .sidebar-tag { display: inline-block; cursor: pointer; text-decoration: none; color: #555; margin: 0 5px 2px 0; padding: 1px 6px; border-radius: 4px; border: 1px solid transparent; } .sidebar-tag:hover { background: #EFEFEF; color: #111; } .sidebar-tag.active { background: #f09199; color: #fff; font-weight: bold; border-color: #e38aa1; } /* ——AI 标签—— */ .gadget-tags-container { display: inline-block; margin-left: 8px; vertical-align: middle; } .gadget-tag { display: inline-block; margin: 0 4px 4px 0; background: #f2f2f2; color: #777; font-size: 10px; padding: 1px 6px; border-radius: 4px; line-height: 1.5; cursor: pointer; } .gadget-tag.active { background: #f09199; color: #fff; } /* ——折叠区—— */ h2.subtitle.collapsible { cursor: pointer; position: relative; padding-left: 20px; user-select: none; } h2.subtitle.collapsible::before { content: '▶'; position: absolute; left: 0; top: 50%; transform: translateY(-50%); font-size: .8em; transition: transform .2s; } h2.subtitle.collapsible.expanded::before { transform: translateY(-50%) rotate(90deg); } /* ——分页—— */ .gadgets-pagination { text-align: center; margin: 10px 0; } .gadgets-pagination .page_inner input.inputtext { width: 30px; padding: 3px 5px; } /* ——加载遮罩—— */ #gadgets-list-container { position: relative; } .gadgets-loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.7); z-index: 10; display: flex; align-items: center; justify-content: center; color: #f09199; font-weight: bold; } /* --- 黑暗模式适配 --- */ html[data-theme='dark'] #gadgets-filter-app .inputtext, html[data-theme='dark'] #gadgets-filter-app .select { background-color: #303132; color: #e0e0e1; border-color: #5c5c5c; } html[data-theme='dark'] #gadgets-filter-app .btn_reset { background-color: #6e6e6e; border-color: #7c7c7c; color: #fdfdfd; } html[data-theme='dark'] #gadgets-filter-app .btn_reset:hover { background-color: #7d7d7d; } html[data-theme='dark'] #gadgets-filter-app .filter-info { color: #dcdcdc; } html[data-theme='dark'] .gadget-tag-list-container { background-color: #353535; border-color: #444; } html[data-theme='dark'] .sidebar-tag { color: #dcdcdc; } html[data-theme='dark'] .sidebar-tag:hover { background-color: #444; color: #fff; } html[data-theme='dark'] .sidebar-tag.active { background: #f09199; color: #fff; } html[data-theme='dark'] .gadget-tag { background-color: #3d3d3f; color: #d8d8d8; } html[data-theme='dark'] .gadget-tag.active { background: #f09199; color: #fff; } html[data-theme='dark'] .gadgets-loading-overlay { background: rgba(45, 46, 47, 0.8); color: #f09199; } html[data-theme='dark'] #gadgets-list li { border-bottom-color: #444; } html[data-theme='dark'] #gadgets-list li:hover { background-color: #353535; } html[data-theme='dark'] #gadgets-list li a.l { color: #2ea6ff; } html[data-theme='dark'] #gadgets-list li small.grey { color: #d8d8d8; } `); class GadgetBrowser { cacheDuration = 7 * 24 * 60 * 60 * 1000; state = { page: 1, limit: 25, currentDOM: [], totalPages: 1, isAISummaryActive: false, }; ui = {}; origDOM = []; allGadgetData = []; gadgetDataMap = new Map(); tagFilterSet = new Set(); origSummaries = new Map(); async init() { const colB = document.getElementById('columnB'); const header = [...document.querySelectorAll('#columnA h2.subtitle')].find(h => h.textContent.trim() === '全部组件'); this.ul = header?.nextElementSibling?.nextElementSibling; if (!colB || !this.ul || this.ul.tagName !== 'UL') return; this.ul.id = 'gadgets-list'; const container = document.createElement('div'); container.id = 'gadgets-list-container'; this.ul.parentNode.insertBefore(container, this.ul); container.appendChild(this.ul); this.injectUI(); this.cacheUIElements(); this.setLoading(true, '正在加载组件数据...'); try { await this.fetchAndProcessAllData(); document.querySelectorAll('#columnA li.tml_item[id^="item_"]').forEach(li => { const summaryP = li.querySelector('.info p:not(.alarm)'); if (summaryP) this.origSummaries.set(li.id, summaryP.innerHTML); this.enhanceListItem(li); }); this.loadAndApplySummaryState(); // 【改动】加载并应用持久化的摘要状态 this.origDOM = [...this.ul.children]; this.populateFiltersFromLocalData(); this.setLoading(false); } catch (e) { console.error('组件数据加载或处理失败', e); this.setLoading(false); this.ui.info.textContent = '组件数据加载失败,请刷新。'; return; } this.setupCollapse(); this.bindEvents(); if (!this.applyFromURL()) this.reset(false); } injectUI() { const sidePanelH2 = document.querySelector('#columnB .SidePanel h2'); if (!sidePanelH2) return; const filterAppHTML = ` <div id="gadgets-filter-app"> <h2 class="subtitle">组件浏览器</h2> <p class="filter-info"></p> <ul class="grouped"> <li><input type="search" id="gadget-key" class="inputtext" placeholder="关键词搜索…"></li> <li><select id="gadget-cat" class="select"><option value="">所有分类</option></select></li> <li><div id="gadget-tag-list" class="gadget-tag-list-container">加载标签中...</div></li> <li><select id="gadget-year" class="select"><option value="">所有年份</option></select></li> <li><button id="gadget-go" class="btn btn_search">筛选</button></li> <li><button id="gadget-clear" class="btn btn_reset">重置筛选</button></li> </ul> </div> `; sidePanelH2.insertAdjacentHTML('afterend', filterAppHTML); const filterButtonLI = document.querySelector('#gadget-go').parentElement; if (filterButtonLI) { const summaryToggleLI = document.createElement('li'); summaryToggleLI.innerHTML = `<button id="gadget-toggle-summary" class="btn btn_reset">显示 AI 摘要</button>`; filterButtonLI.parentElement.insertBefore(summaryToggleLI, filterButtonLI); } const filterAppDiv = document.getElementById('gadgets-filter-app'); if (filterAppDiv) { const hr = document.createElement('hr'); hr.className = 'board'; filterAppDiv.after(hr); } this.ul.insertAdjacentHTML('afterend', '<div id="gadgets-page" class="gadgets-pagination"></div>'); } cacheUIElements() { Object.assign(this.ui, { key: document.getElementById('gadget-key'), cat: document.getElementById('gadget-cat'), tagList: document.getElementById('gadget-tag-list'), year: document.getElementById('gadget-year'), go: document.getElementById('gadget-go'), clear: document.getElementById('gadget-clear'), info: document.querySelector('.filter-info'), pager: document.getElementById('gadgets-page'), listContainer: document.getElementById('gadgets-list-container'), toggleSummaryBtn: document.getElementById('gadget-toggle-summary'), }); } async fetchAndProcessAllData() { const cacheKey = 'bgm_gadgets_all_data'; const cached = GM_getValue(cacheKey); if (cached && cached.timestamp + this.cacheDuration > Date.now()) { this.allGadgetData = cached.data; } else { const rawData = await this.fetchWithFallback(); GM_setValue(cacheKey, { data: rawData, timestamp: Date.now() }); this.allGadgetData = rawData; } this.allGadgetData.forEach(gadget => { try { gadget.ai_category_obj = JSON.parse(gadget.ai_category || '{}'); gadget.ai_tags_arr = JSON.parse(gadget.ai_tags || '[]'); } catch (e) { gadget.ai_category_obj = { main: '未知', sub: '未知' }; gadget.ai_tags_arr = []; } this.gadgetDataMap.set(`item_${gadget.gadget_id}`, gadget); }); } fetchWithFallback() { const PRIMARY_JSON = 'https://gist.githubusercontent.com/WakabaYu/18f0f597676165b1b052a4aee4c8eba7/raw/gadgets_classified.json'; const BACKUP_JSON = 'https://gadgets.ry.mk/gadgets_classified.json'; const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('primary timeout')), 3000)); return Promise.race([this.fetchJson(PRIMARY_JSON), timeout]).catch(() => this.fetchJson(BACKUP_JSON)); } populateFiltersFromLocalData() { this.ui.go.disabled = true; try { const categories = {}, tags = new Set(), years = new Set(); this.allGadgetData.forEach(g => { const cat = g.ai_category_obj; if (cat?.main && cat.sub) { if (!categories[cat.main]) categories[cat.main] = new Set(); categories[cat.main].add(cat.sub); } g.ai_tags_arr.forEach(t => tags.add(t)); if (g.latest_version_date) { const year = g.latest_version_date.substring(0, 4); if (/^\d{4}$/.test(year)) years.add(year); } }); for (const main of Object.keys(categories).sort()) { const og = document.createElement('optgroup'); og.label = main; [...categories[main]].sort().forEach(sub => og.appendChild(new Option(' ' + sub, `${main}:${sub}`))); this.ui.cat.appendChild(og); } this.ui.tagList.innerHTML = ''; const tagFrag = document.createDocumentFragment(); [...tags].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')).forEach(t => { const tagEl = document.createElement('a'); tagEl.href = '#'; tagEl.className = 'sidebar-tag'; tagEl.textContent = t; tagEl.dataset.tag = t; tagFrag.appendChild(tagEl); tagFrag.appendChild(document.createTextNode(' ')); }); this.ui.tagList.appendChild(tagFrag); [...years].sort((a, b) => b - a).forEach(y => this.ui.year.appendChild(new Option(`${y}年`, y))); } catch (e) { console.error('筛选器数据生成失败', e); } finally { this.ui.go.disabled = false; } } filter() { this.setLoading(true); this.updateURL(); const keyword = this.ui.key.value.trim().toLowerCase(); const category = this.ui.cat.value, year = this.ui.year.value; const selectedTags = [...this.tagFilterSet]; const hasFilters = keyword || category || year || selectedTags.length > 0; if (!hasFilters) { this.state.currentDOM = [...this.origDOM]; } else { const matchedGadgets = this.allGadgetData.filter(g => { if (keyword && !g.name.toLowerCase().includes(keyword) && !(g.ai_summary || '').toLowerCase().includes(keyword)) return false; if (category) { const [main, sub] = category.split(':'); if (g.ai_category_obj.main !== main || g.ai_category_obj.sub !== sub) return false; } if (year && (!g.latest_version_date || !g.latest_version_date.startsWith(year))) return false; if (selectedTags.length > 0 && !selectedTags.every(t => new Set(g.ai_tags_arr).has(t))) return false; return true; }); const matchedIds = new Set(matchedGadgets.map(g => `item_${g.gadget_id}`)); this.state.currentDOM = this.origDOM.filter(node => matchedIds.has(node.id)); } this.state.page = 1; this.render(); this.setLoading(false); } render() { const { page, limit, currentDOM } = this.state; const totalItems = currentDOM.length; this.state.totalPages = Math.max(1, Math.ceil(totalItems / limit)); if (this.state.page > this.state.totalPages) this.state.page = this.state.totalPages; const start = (this.state.page - 1) * limit; this.ul.innerHTML = ''; this.ui.info.textContent = `共 ${totalItems} 个组件`; if (!totalItems) { this.ul.innerHTML = '<li>没有找到符合条件的组件。</li>'; this.ui.pager.innerHTML = ''; return; } const frag = document.createDocumentFragment(); for (const li of currentDOM.slice(start, start + limit)) { const clone = li.cloneNode(true); this.enhanceListItem(clone); if (this.state.isAISummaryActive) { const summaryP = clone.querySelector('.info p:not(.alarm)'); if (summaryP) { const itemData = this.gadgetDataMap.get(clone.id); summaryP.innerHTML = itemData?.ai_summary || '<small class="grey"><i>(无 AI 摘要)</i></small>'; } } frag.appendChild(clone); } this.ul.appendChild(frag); this.renderPager(); this.syncTagUIs(); } enhanceListItem(li) { const itemData = this.gadgetDataMap.get(li.id); if (!itemData) return; const badge = li.querySelector('h3 .badge_job'); if (badge && itemData.ai_category_obj?.sub) badge.textContent = itemData.ai_category_obj.sub; if (!itemData.ai_tags_arr?.length) return; const link = li.querySelector('h3 a.l'); if (link && !li.querySelector('.gadget-tags-container')) { const span = document.createElement('span'); span.className = 'gadget-tags-container'; span.innerHTML = itemData.ai_tags_arr.map(t => `<span class="gadget-tag" data-t="${t}">${t}</span>`).join(''); link.after(span); } } renderPager() { const { page, totalPages } = this.state; if (totalPages <= 1) { this.ui.pager.innerHTML = ''; return; } let html = '<div class="page_inner">'; html += page > 1 ? `<a href="#" data-p="${page - 1}" class="p">‹‹</a>` : '<span class="p_edge">‹‹</span>'; let last = 0; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) { if (i > last + 1) html += '<span class="p_gap">...</span>'; html += i === page ? `<strong class="p_cur">${i}</strong>` : `<a href="#" data-p="${i}" class="p">${i}</a>`; last = i; } } html += page < totalPages ? `<a href="#" data-p="${page + 1}" class="p">››</a>` : '<span class="p_edge">››</span>'; html += `<a class="p_pages"><input class="inputtext" type="text" name="page" value="${page}"></a>`; html += `<span class="p_edge">( ${page} / ${totalPages} )</span></div>`; this.ui.pager.innerHTML = html; } reset(doFilter = true, updateURL = true) { this.ui.key.value = ''; this.ui.cat.value = ''; this.ui.year.value = ''; this.tagFilterSet.clear(); if (updateURL) { const url = new URL(location.href); const params = url.searchParams, orderby = params.get('orderby'); params.forEach((_, key) => params.delete(key)); if (orderby) params.set('orderby', orderby); history.pushState({}, '', url.toString()); } this.state.page = 1; if (doFilter) this.filter(); else { this.state.currentDOM = [...this.origDOM]; this.render(); } } updateURL() { const url = new URL(location.href); const params = url.searchParams, orderby = params.get('orderby'); params.forEach((_, key) => params.delete(key)); if (orderby) params.set('orderby', orderby); if (this.ui.key.value.trim()) params.set('q', this.ui.key.value.trim()); if (this.ui.cat.value) params.set('category', this.ui.cat.value); if (this.ui.year.value) params.set('year', this.ui.year.value); this.tagFilterSet.forEach(t => params.append('tag', t)); if (this.state.page > 1) params.set('page', this.state.page); history.pushState({}, '', url.toString()); } applyFromURL() { const q = new URLSearchParams(location.search); if (!q.has('q') && !q.has('category') && !q.has('tag') && !q.has('year')) return false; this.ui.key.value = q.get('q') || ''; this.ui.cat.value = q.get('category') || ''; this.ui.year.value = q.get('year') || ''; this.tagFilterSet = new Set(q.getAll('tag')); this.syncTagUIs(); const keyword = this.ui.key.value.trim().toLowerCase(); const category = this.ui.cat.value, year = this.ui.year.value; const selectedTags = [...this.tagFilterSet]; if (!keyword && !category && !year && selectedTags.length === 0) { this.state.currentDOM = [...this.origDOM]; } else { this.state.currentDOM = this.origDOM.filter(node => { const itemData = this.gadgetDataMap.get(node.id); if (!itemData) return false; if (keyword && !itemData.name.toLowerCase().includes(keyword) && !(itemData.ai_summary || '').toLowerCase().includes(keyword)) return false; if (category) { const [main, sub] = category.split(':'); if (itemData.ai_category_obj.main !== main || itemData.ai_category_obj.sub !== sub) return false; } if (year && (!itemData.latest_version_date || !itemData.latest_version_date.startsWith(year))) return false; if (selectedTags.length > 0 && !selectedTags.every(t => new Set(itemData.ai_tags_arr).has(t))) return false; return true; }); } this.state.page = parseInt(q.get('page') || '1', 10) || 1; this.render(); return true; } syncTagUIs() { this.ui.tagList.querySelectorAll('.sidebar-tag').forEach(el => { el.classList.toggle('active', this.tagFilterSet.has(el.dataset.tag)); }); document.querySelectorAll('#gadgets-list .gadget-tag').forEach(el => { el.classList.toggle('active', this.tagFilterSet.has(el.dataset.t)); }); } loadAndApplySummaryState() { const isActive = GM_getValue('bgm_gadgets_ai_summary_state', false); this.state.isAISummaryActive = isActive; this.ui.toggleSummaryBtn.textContent = isActive ? '显示原始简介' : '显示 AI 摘要'; if (isActive) { document.querySelectorAll('#columnA li.tml_item[id^="item_"]').forEach(li => { const summaryP = li.querySelector('.info p:not(.alarm)'); if (!summaryP) return; const itemData = this.gadgetDataMap.get(li.id); summaryP.innerHTML = itemData?.ai_summary || '<small class="grey"><i>(无 AI 摘要)</i></small>'; }); } } toggleSummaries() { this.state.isAISummaryActive = !this.state.isAISummaryActive; const isActive = this.state.isAISummaryActive; GM_setValue('bgm_gadgets_ai_summary_state', isActive); this.ui.toggleSummaryBtn.textContent = isActive ? '显示原始简介' : '显示 AI 摘要'; document.querySelectorAll('#columnA li.tml_item[id^="item_"]').forEach(li => { const summaryP = li.querySelector('.info p:not(.alarm)'); if (!summaryP) return; if (isActive) { const itemData = this.gadgetDataMap.get(li.id); summaryP.innerHTML = itemData?.ai_summary || '<small class="grey"><i>(无 AI 摘要)</i></small>'; } else { summaryP.innerHTML = this.origSummaries.get(li.id) || ''; } }); } bindEvents() { this.ui.go.addEventListener('click', () => this.filter()); this.ui.clear.addEventListener('click', () => this.reset()); ['key', 'cat', 'year'].forEach(k => this.ui[k].addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); this.filter(); } }) ); const handleTagClick = (tag) => { this.tagFilterSet.has(tag) ? this.tagFilterSet.delete(tag) : this.tagFilterSet.add(tag); this.syncTagUIs(); this.filter(); }; this.ui.tagList.addEventListener('click', e => { e.preventDefault(); const tagEl = e.target.closest('.sidebar-tag'); if (tagEl) handleTagClick(tagEl.dataset.tag); }); this.ul.addEventListener('click', e => { const tagEl = e.target.closest('.gadget-tag'); if (tagEl) { e.preventDefault(); handleTagClick(tagEl.dataset.t); } }); this.ui.pager.addEventListener('click', e => { const a = e.target.closest('a[data-p]'); if (a) { e.preventDefault(); this.state.page = parseInt(a.dataset.p, 10); this.updateURL(); this.render(); } }); this.ui.pager.addEventListener('keydown', e => { if (e.target.name === 'page' && e.key === 'Enter') { e.preventDefault(); const p = parseInt(e.target.value, 10); if (p > 0 && p <= this.state.totalPages) { this.state.page = p; this.updateURL(); this.render(); } } }); this.ui.toggleSummaryBtn.addEventListener('click', () => this.toggleSummaries()); window.addEventListener('popstate', () => { if (!this.applyFromURL()) this.reset(false, false); }); } setupCollapse() { document.querySelectorAll('#columnA h2.subtitle').forEach(h => { if (['我的组件', '启用的组件'].includes(h.textContent.trim())) { const ul = h.nextElementSibling; if (ul?.tagName === 'UL') { ul.style.display = 'none'; h.classList.add('collapsible'); h.addEventListener('click', () => { ul.style.display = ul.style.display === 'none' ? '' : 'none'; h.classList.toggle('expanded', ul.style.display !== 'none'); }); } } }); } setLoading(isLoading, text = '正在筛选...') { this.ui.go.disabled = isLoading; let overlay = this.ui.listContainer.querySelector('.gadgets-loading-overlay'); if (isLoading) { this.ui.info.textContent = text; if (!overlay) { overlay = document.createElement('div'); overlay.className = 'gadgets-loading-overlay'; this.ui.listContainer.appendChild(overlay); } overlay.textContent = text; overlay.style.display = 'flex'; } else { if (overlay) overlay.style.display = 'none'; } } fetchJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'json', onload: r => (r.status >= 200 && r.status < 400) ? resolve(r.response) : reject(new Error(`HTTP error! status: ${r.status}`)), onerror: err => reject(err), ontimeout: () => reject(new Error('Request timed out')), }); }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => new GadgetBrowser().init()); } else { new GadgetBrowser().init(); } })();