// ==UserScript== // @name 班固米-条目职位自定义排序与折叠 // @namespace https://github.com/weiduhuo/scripts // @version 1.2.1-1.0 // @description 对[动画]条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置 // @author weiduhuo // @match *://bgm.tv/subject/* // @match *://bgm.tv/settings/privacy // @match *://bangumi.tv/subject/* // @match *://bangumi.tv/settings/privacy // @match *://chii.in/subject/* // @match *://chii.in/settings/privacy // @grant none // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const SCRIPT_NAME = '班固米-职位排序组件'; const TARGET_SUBJECT_TYPES = ['anime']; // ['anime', 'book', 'music', 'game', 'real']; /* 职位的排序列表 jobOrder 与默认折叠的职位 foldableJobs 的合并信息 * 基本类型: type = [Job | [boolean | Job, ...Job[]]] Job = string | RegExp * 其中 boolean 表示子序列内的职位是否默认折叠,缺损值为 False,需位于子序列的首位才有意义 * (下文 `,,` 表示插入 null 元素,用于输出格式化文本时标记换行 ) */ const staffMapList = [, "中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"], , "放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/, , , "原作", "原案", "人物原案", "原作插图", [true, "原作协力"], , "团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督", , "系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/], , "分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"], , "人物设定", , , "总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/, , "主动画师", "主要动画师", [true, "构图"], [true, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/, , "动画检查", [true, /动画检查/], , , "设定", "背景设定", "道具设计", /(? { const matchingRoles = []; // 1.正则匹配 if (item instanceof RegExp) { matchingRoles.push(...Object.keys(staffDict).filter(key => item.test(key))); if (matchingRoles.length) { console.log(`${SCRIPT_NAME}:使用正则表达式 "${item}" 成功匹配 \{${matchingRoles}\}`); } else return; } else if (typeof item === 'string') { // 2.键值匹配 if (item && item in staffDict) { matchingRoles.push(item); // 3.特殊关键字处理 } else if (item.startsWith('==')) { // 激活待插入位置 insterTag = true; insertFold = foldableJobs.includes(item); } else return // 4.其余情形均忽略(且对于意外类型不报错) } else return; // 添加职位,并判断是否默认折叠 matchingRoles.forEach(role => { const li = document.createElement('li'); li.innerHTML = staffDict[role]; if (typeof item === 'string' && foldableJobs.includes(role) || item instanceof RegExp && foldableJobs.includes(item)) { li.classList.add('folded', 'foldable'); if (!hasFolded) hasFolded = true; } ul.appendChild(li); delete staffDict[role]; // 从字典中删除已处理的职位 // 保存待插入位置 if (insterTag) { liAfterIntsert = li; insterTag = false; } }); }); // 将剩余未被匹配的职位按原顺序添加到待插入位置 const unmatchedJobs = Object.keys(staffDict); if (unmatchedJobs.length === 0) { return; } unmatchedJobs.forEach(role => { const li = document.createElement('li'); li.innerHTML = staffDict[role]; if (insertFold) li.classList.add('folded', 'foldable'); if (liAfterIntsert) ul.insertBefore(li, liAfterIntsert); // 未设置待插入位置,则默认插入到末尾,且默认不折叠 else ul.appendChild(li); }); console.log(`${SCRIPT_NAME}:未能匹配到的职位 ${JSON.stringify(staffDict, null, 2)}`); if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`); } function isTargetSubjectType() { return TARGET_SUBJECT_TYPES.includes(getSubjectType()); } /* 巧妙地使用非常便捷的方法,获取当前条目的类型 * 源自 https://bangumi.tv/dev/app/2723/gadget/1242 * 替代了下方的原有方法 */ function getSubjectType() { const href = document.querySelector("#navMenuNeue .focus").getAttribute("href"); return href.split("/")[1]; } /*async function isTargetMediaType() { const smallTag = document.querySelector('h1.nameSingle > small.grey'); if (smallTag) { // 优先通过网页内容判断 const text = smallTag.innerText.trim(); return ['TV', 'WEB', '剧场版', 'OVA'].includes(text); } else { // 通过API查询 const urlParts = location.href.split('/'); const subjectID = urlParts[urlParts.length - 1]; const response = await fetch(`https://api.bgm.tv/v0/subjects/${subjectID}`); const subject = await response.json(); return subject.type === 2; // 判断是否为动画类别 } }*/ // 获取一个字典来存储网页中的职位信息 function getStaffDict() { const staffDict = {}; const lis = document.querySelectorAll('#infobox > li'); lis.forEach(li => { const tip = li.querySelector('span.tip'); if (tip) { const role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号 staffDict[role] = li.innerHTML; } }); return staffDict; } // 为网页原有的 `folded` 类别添加 `foldable` 便签,用于实现切换 function addFoldableTag() { const lis = document.querySelectorAll('#infobox > li'); lis.forEach(li => { if (li.classList.contains('folded')) { li.classList.add('foldable'); if (!hasFolded) hasFolded = true; } }); } /* 将原本存在的 `更多制作人员` 一次性按钮,转绑新事件,并改为永久性开关 * 使用网页原有的 `folded` 元素类别,实现对立于 sortStaff 功能 * 添加不存在的 `更多制作人员` 按钮,否则一些职位信息将不可见
更多制作人员 +
*/ function changeToToggleButton() { const buttonValue = { on: '更多制作人员 +', off: '更多制作人员 -' }; const parent = document.querySelector('.infobox_container'); let moreLink = parent.querySelector('.infobox_expand a'); if (!hasFolded) { // 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏 if (moreLink) { moreLink.style.display = 'none'; console.log(`${SCRIPT_NAME} - 将原有的 '${buttonValue.on}' 隐藏`); } return; } if (!moreLink) { moreLink = createElement('a', { href: 'javascript:void(0)' }, buttonValue.on); const expand = createElement('div', { class: 'infobox_expand' }, [moreLink]); parent.appendChild(expand); console.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonValue.on}' 按钮`); } moreLink.addEventListener('click', function (event) { event.stopImmediatePropagation(); // 阻止其他事件的触发 const foldedLis = document.querySelectorAll('.foldable'); const isHidden = moreLink.innerText == buttonValue.on; foldedLis.forEach(li => { if (isHidden) { li.classList.remove('folded'); } else { li.classList.add('folded'); } }); moreLink.innerText = isHidden ? buttonValue.off : buttonValue.on; }, { capture: true }); // 使事件处理函数在捕获阶段运行 } /* 创建用户设置 UI 界面 * 仿照 #columnA 中的同类元素进行构建,使用原有的结构与样式

条目职位排序 · 默认折叠的职位

*/ function buildSettingUI(mainStyle) { const mainTitle = createElement('tr', null, [ createElement('td', { colSpan: '2' }, [ createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠的职位') ]) ]); const animeBlock = buildAnimeBlock(); const ui = createElement('div', mainStyle, [ createElement('table', { class: 'settings', style: { marginLeft: '5px' } }, [ createElement('colgroup', null, [ createElement('col', { style: { width: '90%' } }), createElement('col'), ]), createElement('tbody', null, [ mainTitle, animeBlock // 可拓展其他类型条目的模块 ]) ]) ]); return ui; } /* 创建 staffMapList 文本内容编辑界面 * 对于 textarea, button 等控件仍然使用原有的结构与样式

动画条目

*/ function buildAnimeBlock() { // 搭建标题 const subTitle = createElement('h2', { class: 'subtitle' }, '动画条目'); // 搭建简易提示框 const msgCntr = createElement('p', { class: 'tip_j', style: { display: 'none' } }); // 搭建文本框 let hasInputted = false; let {text, isDefault} = getMapListText(false); if (isDefault) setMessage(msgCntr, '现为默认设置'); // 初始化时,提醒用户已为默认设置 const textArea = createElement('textarea', { class: 'quick markItUpEditor hasEditor codeHighlight', id: 'staff_map_list', name: 'staff_map_list', style: { fontSize: '13x', lineHeight: '21px' } }, text, { input: () => { if (!hasInputted) hasInputted = true; if (isDefault) isDefault = false; // console.log("IS INPUTTING"); } }); // 搭建提交按钮 const submitBtn = createElement('input', { class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存', style: { marginRight: '5px' } }, null, { click: () => { // 判断是否为重置后未对默认内容进行修改 if (isDefault && !hasInputted) { resetMapList(); setMessage(msgCntr, '保存成功!恢复默认设置'); // 恢复初始状态 hasInputted = false; return; } const [modifiedData, isModified] = modifyMapListJSON(textArea.value); // 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除 const savedDate = `[${modifiedData}]`; const parsedData = parseMapListJSON(savedDate); if (parsedData) { // 保存数据 saveMapListText(savedDate); // 页面显示 if (isModified) trySetText(textArea, msgCntr, modifiedData, '保存成功!并自动纠错', true); else setMessage(msgCntr, '保存成功!'); } else setMessage(msgCntr, '保存失败!格式存在错误'); // 恢复初始状态 hasInputted = false; } }); // 搭建重置按钮 const resetBtn = createElement('input', { class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认', style: { marginRight: '10px' } }, null, { click: async () => { if (isDefault) { setMessage(msgCntr, '已为默认内容'); return; } await trySetText(textArea, msgCntr, getMapListText(true).text, '已恢复默认内容', false); // 需进行同步等待,由于 setText 可能会触发 input 事件 isDefault = true; hasInputted = false; } }); // 搭建外部结构 const textCntr = createElement('div', { class: 'markItUp' }, [textArea]); const animeBlock = createElement('tr', null, [ createElement('td', null, [ subTitle, // 可拓展折叠效果 createElement('div', null, [textCntr, submitBtn, resetBtn, msgCntr]) ]), createElement('td') ]); return animeBlock; } /* 优先尝试使用 execCommand 方法改写文本框,使得改写前的用户历史记录不被浏览器清除 * (虽然 execCommand 方法已被弃用...但仍然是实现该功能最便捷的途径) */ async function trySetText(textArea, msgCntr, text, msg, isRestore, transTime = 100) { let [scrollVert, scrollHoriz, cursorPos] = savePos(); try { setMessage(msgCntr); await clearAndSetTextarea(textArea, text, transTime); setMessage(msgCntr, `${msg},可快捷键撤销`, 0); } catch (e) { textArea.value = ''; await new Promise(resolve => setTimeout(resolve, transTime)); textArea.value = text; setMessage(msgCntr, msg, 0); console.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`) } if (isRestore) restorePos(); // 保存滚动位置和光标位置 function savePos() { return [textArea.scrollTop, textArea.scrollLeft, textArea.selectionStart]; } // 恢复滚动位置和光标位置 function restorePos() { const currentTextLen = textArea.value.length; if (cursorPos > currentTextLen) cursorPos = currentTextLen; textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight); // textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth); textArea.setSelectionRange(cursorPos, cursorPos); } } async function clearAndSetTextarea(textarea, newText, timeout = 100) { textarea.focus(); // 全选文本框内容并删除 textarea.setSelectionRange(0, textarea.value.length); document.execCommand('delete'); // 延迟一段时间后,插入新的内容 await new Promise(resolve => setTimeout(resolve, timeout)); document.execCommand('insertText', false, newText); } async function setMessage(container, message, timeout = 100) { container.style.display = 'none'; if (!message) return; // 无信息输入,则隐藏 // 隐藏一段时间后,展现新内容 if (timeout) await new Promise(resolve => setTimeout(resolve, timeout)); container.textContent = message; container.style.display = 'inline'; } function loadMapList() { // 读取可能的非默认设置 let jsonString = localStorage.getItem('BangumiStaffSorting_animeStaffMapList'); if (jsonString) { let parsedData = parseMapListJSON(jsonString); if (parsedData) { // 修复外层重复嵌套 `[]` 的形式 (忽略存在的漏洞,形如:[[true, ["a"], "b"]] ) if (parsedData.length === 1 && Array.isArray(parsedData[0]) && typeof parsedData[0][0] !== 'boolean') { parsedData = parsedData[0]; } staffMapList.length = 0; staffMapList.push(...parsedData); } else console.log(`${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据`); } // 将数据拆解为 jobOrder 与 foldableJobs staffMapList.forEach(item => { if (Array.isArray(item) && item.length) { // 对数组进行完全展平,提高对非标多层数组的兼容性 item = item.flat(Infinity); // 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效 // 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效 if (typeof item[0] === 'boolean') { if (item[0]) foldableJobs.push(...item.slice(1)); jobOrder.push(...item.slice(1)); } else { jobOrder.push(...item); } } else if (typeof item !== 'undefined') { jobOrder.push(item); } }); } function resetMapList() { localStorage.removeItem('BangumiStaffSorting_animeStaffMapList'); console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`) } function saveMapListText(jsonStr) { localStorage.setItem('BangumiStaffSorting_animeStaffMapList', jsonStr); console.log(jsonStr); console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`); } function getMapListText(useDefault) { let jsonStr = null; if (!useDefault) { jsonStr = localStorage.getItem('BangumiStaffSorting_animeStaffMapList'); } const isDefault = jsonStr === null; if (jsonStr) { jsonStr = jsonStr.slice(1, -1); // 消除首尾的 `[]` } else if (mapListTextBuffer) { jsonStr = mapListTextBuffer; } else { // 将默认数据转化为格式化文本 jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace( /(null,\n )|(\n\s+)/g, (match, g1, g2) => { if (g1) return '\n'; if (g2) return ' '; return match; }).slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]` // 使得 `[ `->`[` 同时 ` ]`->`]` // jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace( // /(null,)|(? { // if (g1) return '\n'; // if (g2) return ' '; // if (g3) return '['; // if (g4) return '],'; // return match; // }).slice(3, -2); mapListTextBuffer = jsonStr; } return {text: jsonStr, isDefault: isDefault}; } /* 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足 JSON 格式 * 已基本兼容 JS 格式的文本数据,实现格式转化 * group2 与 group4 致使正则表达式中不允许出现 [/'"] 三种字符 */ function modifyMapListJSON(text) { let flags = new Array(10).fill(false); const rslt = text.replace( /(,\s*(?=]|$))|(,\s*)+(?=,)|(')|(? { isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1); isTriggered(2, '删除连续重复的 `,` 逗号', g2); isTriggered(1, '将单引号替换为双引号', g3); isTriggered(3, '将正则表达式以双引号包裹', g4); if (g1 || g2) return ''; if (g3) return '"'; if (g4) return `"${match}"`; return match; }); return [rslt, booleanOr(...flags)]; function isTriggered(index, msg, ...groups) { if (!flags[index] && booleanOr(...groups)) { console.log(`${SCRIPT_NAME}:触发自动纠错 - ${msg}`); flags[index] = true; } } function booleanOr(...values) { return values.reduce((acc, val) => acc || val, false); } } /* 初步解析 staffMapList JSON 字符串 * 仅检查: * 1.是否满足 JSON 格式 * 2.是否为数组类型 * 3.字符串样式的正则表达式,是否满足规定格式 * 更进一步的解析,将在 loadMapList 中进行 */ function parseMapListJSON(text) { let parsedData; try { parsedData = JSON.parse(text, regexReviver); } catch (e) { console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`); return null; } if (!Array.isArray(parsedData)) { console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数类型`); return null; } return parsedData; } // 解析 JSON 字符串中的正则表达式 function regexReviver(key, value) { if (typeof value === 'string' && value.startsWith('/')) { const regexParttern = /^\/(.+)\/([gimsuy]*)$/; const match = value.match(regexParttern); if (match) { try { return new RegExp(match[1], match[2]); } catch (e) { throw new Error(`正则表达式 "${value}" 非法 - ${e}`); } } else throw new Error(`正则表达式 "${value}" 不符合 ${regexParttern} 格式`); } return value; } // 将正则表达式转化为字符串,以满足 JSON 格式 function regexReplacer(key, value) { if (value instanceof RegExp) { return value.toString(); } return value; } function createElement(tagName, options, subElements, eventHandlers) { const element = document.createElement(tagName); if (options) { for (let opt in options) { if (opt === 'dataset' || opt === 'style') { for (let key in options[opt]) { element[opt][key] = options[opt][key]; } } else if (opt === 'class') { element.className = options[opt]; } else { element[opt] = options[opt]; } } } if (subElements) { updateSubElements(element, subElements); } if (eventHandlers) { for (let e in eventHandlers) { element.addEventListener(e, eventHandlers[e]); } } return element; } function updateSubElements(parent, subElements, isReplace = false) { if (isReplace) parent.innerHTML = ''; if (!subElements) return parent; if (typeof subElements === 'string') subElements = [subElements]; for (let e of subElements) { parent.appendChild(typeof e === 'string' ? document.createTextNode(e) : e); } return parent; } })();