// ==UserScript== // @name 目录内添加条目增强 // @namespace https://bgm.tv/group/topic/409246 // @version 0.3.2 // @description 为 bangumi 增加在目录内搜索条目并添加的功能,添加无需刷新,兼容“目录批量添加与编辑” // @author mmm // @include http*://bgm.tv/index/* // @include http*://chii.in/index/* // @include http*://bangumi.tv/index/* // @icon https://www.google.com/s2/favicons?sz=64&domain=bgm.tv // @grant unsafeWindow // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/516479/%E7%9B%AE%E5%BD%95%E5%86%85%E6%B7%BB%E5%8A%A0%E6%9D%A1%E7%9B%AE%E5%A2%9E%E5%BC%BA.user.js // @updateURL https://update.greasyfork.icu/scripts/516479/%E7%9B%AE%E5%BD%95%E5%86%85%E6%B7%BB%E5%8A%A0%E6%9D%A1%E7%9B%AE%E5%A2%9E%E5%BC%BA.meta.js // ==/UserScript== (function() { 'use strict'; const createFetch = method => async (url, body, serializer = body => JSON.stringify(body)) => { const options = method === 'POST' ? { method, body: serializer(body) } : { method }; try { const response = await fetch(url, options); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const text = await response.text(); try { return JSON.parse(text); } catch { return text; } } catch (e) { console.error(e); return null; } }; const fetchGet = createFetch('GET'); const fetchPost = createFetch('POST'); const postSearch = async (cat, keyword, { filter = {}, offset = 0 }) => { const url = `https://api.bgm.tv/v0/search/${cat}?limit=10&offset=${offset}`; const body = { keyword, filter }; const result = await fetchPost(url, body); return result?.data; }; const searchSubject = async (keyword, { type = '', start = 0 }) => { // 旧API结果为空时发生CORS错误,但新API搜索结果不准确,仍用旧API const url = `https://api.bgm.tv/search/subject/${encodeURIComponent(keyword)}?type=${type}&max_results=10&start=${start}`; const result = await fetchGet(url); return result?.list; }; // const searchSubject = (keyword, type) => postSearch('subjects', keyword, { type: [+type].filter(a => a) }); const searchPrsn = postSearch.bind(null, 'persons'); const searchCrt = postSearch.bind(null, 'characters'); const getSearchMethod = { 'subject': [searchSubject, 'start'], 'person': [searchPrsn, 'offset'], 'character': [searchCrt, 'offset'], 'ep': [searchSubject, 'start'], }; const getEps = async (subject_id) => { const url = `https://api.bgm.tv/v0/episodes?subject_id=${subject_id}`; const result = await fetchGet(url); return result?.data; }; const formhash = document.querySelector('input[name=formhash]').value; const addItem = async (add_related) => { const url = `${ location.pathname }/add_related`; const body = { formhash, add_related, submit: '添加条目关联' }; const result = await fetchPost(url, body, body => new URLSearchParams(body)); return result; }; const modifyItem = async (id, content, order) => { const url = `/index/related/${ id }/modify`; const body = { formhash, content, order, submit: '提交' }; const result = await fetchPost(url, body, body => new URLSearchParams(body)); return result; }; document.querySelector('li.add a').addEventListener('click', () => { document.querySelector('#TB_window').style.height = 'unset'; document.querySelector('#TB_ajaxContent').style.height = '250px'; }); const boxes = document.querySelectorAll('.newIndexSection'); boxes.forEach((box) => { const cat = ['subject', 'character', 'person', 'ep'][box.id.at(-1)]; const input = box.querySelector('.inputtext'); input.style.position = 'sticky'; input.style.top = 0; input.zIndex = 2; // 覆盖ep const result = document.createElement('div'); result.classList.add('subjectListWrapper'); box.firstElementChild.append(result); const btn = makeBtn(); btn.classList.add('chiiBtn'); btn.onclick = async () => { await searchAndRender(cat, input, result); }; box.querySelector('#submitBtnO').append(btn); const makeTip = (text) => { const tip = document.createElement('span'); tip.classList.add('tip'); tip.textContent = text; return tip; }; const contentTextarea = document.createElement('textarea'); contentTextarea.className = 'reply'; const orderInput = document.createElement('input'); orderInput.type = 'text'; orderInput.className = 'inputtext'; input.after(makeTip('评价:'), document.createElement('br'), contentTextarea, document.createElement('br'), makeTip('排序:'), document.createElement('br'), orderInput); const form = box.querySelector('#newIndexRelatedForm'); form.addEventListener('submit', async (e) => { e.preventDefault(); const ukagaka = document.querySelector('#robot'); ukagaka.style.zIndex = '103'; unsafeWindow.chiiLib.ukagaka.presentSpeech('添加中,请稍候...'); const v = input.value.trim(); const add_related = input.value.match(/\d+/) ? `/${cat}/${v}` : v; const id = add_related.split('/').pop(); try { const addedHTML = await addItem(add_related); const content = contentTextarea.value.trim(); const order = parseInt(orderInput.value); const parser = new DOMParser(); const query = `#item_${ cat === 'subject' ? '' : cat }${id}`; const addedDOM = parser.parseFromString(addedHTML, 'text/html'); let added = addedDOM.querySelector(query); if (content || !isNaN(order)) { const rlt = added.querySelector('a.tb_idx_rlt'); const rlt_id = rlt.id.split('_')[1]; const modifiedHTML = await modifyItem(rlt_id, content, order); const modifiedDOM = parser.parseFromString(modifiedHTML, 'text/html'); added = modifiedDOM.querySelector(query); } const previousAnchor = added.previousElementSibling; const nextAnchor = added.nextElementSibling; if (previousAnchor) { document.querySelector(`#${previousAnchor.id}`).after(added); } else if (nextAnchor) { document.querySelector(`#${nextAnchor.id}`).before(added); } else { const parent = added.parentElement; document.querySelector('#columnSubjectBrowserA').append(parent); } added.querySelector('.tools').style.visibility = 'hidden'; // 无法进行同页修改,暂隐藏 const collectBlock = added.querySelector('.collectBlock'); // 只有条目可以收藏 if (collectBlock) collectBlock.style.visibility = 'hidden'; unsafeWindow.chiiLib.ukagaka.presentSpeech('添加成功!', true); } catch (e) { console.error(e); unsafeWindow.chiiLib.ukagaka.presentSpeech('添加失败了T T', true); } finally { setTimeout(() => ukagaka.style.zIndex = '90', 3500); } }); }); function makeBtn(text = '搜索') { const btn = document.createElement('a'); btn.href = 'javascript:;'; btn.innerText = text; return btn; } const makeLoading = (prompt = '搜索中……') => document.createTextNode(prompt); async function searchAndRender(cat, input, result, target=input, append=false) { const [method, key] = getSearchMethod[cat]; const keyword = input.value.trim(); if (keyword === '') return; const loader = (offset) => method(keyword, { [key]: offset }); const clickHandler = e => { e.preventDefault(); if (cat === 'ep') { renderEps(e.currentTarget, target, append); } else { if (append) { target.value += e.currentTarget.href + '\n'; } else { target.value = e.currentTarget.href; } } }; renderList(loader, result, cat, a => a.addEventListener('click', clickHandler)); } const listHTML = (list, cat = 'subject') => { const isEp = cat === 'ep'; if (isEp) cat = 'subject'; return list.reduce((m, { id, type, images, name, name_cn, career, infobox }) => { if (isEp && ![2, 6].includes(type)) return m; // 动画 + 三次元,旧API不支持多重类别筛选 name_cn ??= infobox?.find(({ key }) => key === '简体中文名')?.value; if (cat !== 'subject') cat = career ? 'person' : 'character'; type = cat === 'subject' ? ['书籍', '动画', '音乐', '游戏', '', '三次元'][type - 1] : null; const grid = images?.grid; const exist = v => v ? v : ''; m += `
  • ${ grid ? `` : ''}
    ${ exist(type) }

    ${ name }

    ${ exist(name_cn) }
  • `; return m; }, ''); } const makeMore = (text) => { const more = document.createElement('li'); more.classList.add('clearit'); more.textContent = text; more.style.textAlign = 'center'; more.style.listStyle = 'none'; return more; } const makeMoreBtn = (ul, cat, loader, applyHandler, initStart = 1) => { const searching = makeLoading(); const more = makeMore('加载更多'); more.style.cursor = 'pointer'; more.start = initStart; more.onclick = async () => { more.before(searching); const nextList = await loader(more.start); if (!nextList) { searching.remove(); return; } ul.insertAdjacentHTML('beforeend', listHTML(nextList, cat)); applyHandler(); searching.remove(); if (nextList.length < 10 && !['subject', 'ep'].includes(cat)) { more.replaceWith(makeMore('没有啦')); return; } more.start += nextList.length; } return more; } async function renderList(loader, container, cat, handler = () => {}) { const applyHandler = () => ul.querySelectorAll('a').forEach(handler); const searching = makeLoading(); let initStart = 1; container.innerHTML = ''; container.append(searching); let firstList = await loader(); if (firstListEnd()) return; let firstHTML = listHTML(firstList, cat); while (firstHTML === '' && cat === 'ep') { firstList = await loader(initStart += firstList.length); if (firstListEnd()) return; firstHTML = listHTML(firstList, cat); } const ul = document.createElement('ul'); ul.id = 'subjectList'; ul.classList.add('subjectList', 'ajaxSubjectList'); ul.innerHTML = firstHTML; initStart += firstList.length; const more = firstList.length === 10 || ['subject', 'ep'].includes(cat) ? makeMoreBtn(ul, cat, loader, applyHandler, initStart) : makeMore('没有啦'); container.append(ul, more); applyHandler(); searching.remove(); function firstListEnd() { if (!firstList) { container.textContent = '搜索失败'; return true; } else if (firstList.length === 0) { container.textContent = '未找到相关条目'; return true; } } } const epStyle = document.createElement('style'); epStyle.textContent = ` ul.ajaxSubjectList li ul.prg_list { display: flex; flex-wrap: wrap; li { border-bottom: none; border-top: none; padding: 0; a:hover { color: #333; text-decoration: none; } } } ul.ajaxSubjectList li:hover ul.prg_list li a { color: #06C; } `; document.head.append(epStyle); async function renderEps(elem, target, append) { const parent = elem.closest('li').querySelector('.inner'); const fetching = makeLoading('获取中……'); parent.append(fetching); const eps = await getEps(elem.href.split('/').pop()); const epsByType = Object.groupBy?.(eps, ({ type }) => ['0', 'SP', 'OP', 'ED'][type]) ?? eps.reduce((acc, ep) => { const type = ['0', 'SP', 'OP', 'ED'][ep.type]; if (!acc[type]) acc[type] = []; acc[type].push(ep); return acc; }, {}); fetching.remove(); if (!eps) { parent.append('获取失败'); return; } const ul = document.createElement('ul'); ul.className = 'prg_list clearit'; Object.entries(epsByType).forEach(([type, eps]) => { if (type !== '0') { const subtitle = document.createElement('li'); subtitle.className = 'subtitle'; const span = document.createElement('span'); span.textContent = type; subtitle.append(span); ul.append(subtitle); } eps.map(({ id, name, sort }) => { const li = document.createElement('li'); const a = document.createElement('a'); a.href = `/ep/${ id }`; a.className = 'load-epinfo epBtnAir'; a.title = name; a.textContent = String(sort).padStart(2, '0'); li.onclick = e => { e.preventDefault(); if (append) { target.value += a.href + '\n'; } else { target.value = a.href; } }; li.append(a); ul.append(li); }); }); parent.append(ul); } // 兼容“目录批量添加与编辑”(https://bgm.tv/dev/app/1037) const observer = monitorElement('.bibeBox', bibeBox => { const container = document.createElement('div'); container.style = `display: flex; justify-content: space-evenly; height: 300px; padding: 5px; overflow-y: auto;`; const textarea = bibeBox.querySelector('textarea'); textarea.rows = 8; bibeBox.previousSibling.after(container); bibeBox.parentNode.style.marginTop = '-150px'; const submitWrapper = document.createElement('div'); submitWrapper.style.width = '50%'; submitWrapper.append(bibeBox, document.querySelector('#submit_list')); const searchPanel = document.createElement('div'); searchPanel.style = 'width: 50%' const inputWrapper = document.createElement('div'); inputWrapper.style = `width: fit-content; margin: auto; border-radius: 100px; box-shadow: none; border: 1px solid rgba(200, 200, 200, 0.5); background-color: rgba(255, 255, 255, 0.2);`; const input = document.createElement('input'); input.classList.add('inputtext'); input.type = 'text'; input.addEventListener('keydown', (event) => { if (event.key === 'Enter') newSearchAndRender(); }); input.style = `font-size: 1em; width: 120px; -webkit-appearance: none; -moz-appearance: none; box-shadow: none; background: transparent; line-height: 20px; border: none;`; const result = document.createElement('div'); result.classList.add('subjectListWrapper'); result.style = ` max-height: 250px; overflow-y: scroll; `; const select = document.createElement('select'); select.onchange = newSearchAndRender; select.innerHTML = ` `; select.style = `font-size: 1em; padding: 4px 4px 4px 5px; width: fit-content; border: none; outline: none; box-shadow: none; background-color: transparent; background-image: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; border-radius: 0; border-right: 1px solid rgba(200, 200, 200, 0.5)`; const btn = makeBtn('🔍'); btn.onclick = newSearchAndRender; btn.style = `text-wrap: nowrap; width: 20px; height: 20px; border: none; border-left: 1px solid rgba(200, 200, 200, 0.5); padding: 4px 5px; cursor: pointer;` searchPanel.append(inputWrapper, result); inputWrapper.append(select, input, btn); container.append(submitWrapper, searchPanel); function newSearchAndRender() { const cat = select.value; searchAndRender(cat, input, result, bibeBox.querySelector('textarea'), true); } }); // Microsoft Copilot start function monitorElement(selector, callback) { const targetNode = document.body; // 监视整个文档的变化 const config = { childList: true, subtree: true }; // 配置监视选项 const observer = new MutationObserver((mutationsList, observer) => { for (let mutation of mutationsList) { if (mutation.type === 'childList') { const addedNodes = Array.from(mutation.addedNodes); addedNodes.forEach(node => { if (node.matches?.(selector)) { observer.disconnect(); callback(node); observer.observe(targetNode, config); } else if (node.querySelectorAll) { observer.disconnect(); const matchingElements = node.querySelectorAll(selector); matchingElements.forEach(matchingNode => callback(matchingNode)); observer.observe(targetNode, config); } }); } } }); observer.observe(targetNode, config); return observer; // 返回观察者实例,以便在需要时断开观察 } // end })();