// ==UserScript==
// @name 班固米-条目职位自定义排序与折叠
// @namespace https://github.com/weiduhuo/scripts
// @version 1.3.0-1.1
// @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 DEBOUNCE_DELAY = 500;
// URL 相对路径
const PATHNAME = window.location.pathname;
// 是否对职位信息进行了折叠 (依此判断 `更多制作人员` 开关的必要性)
let hasFolded = false;
// 职位人员信息的行距
let jobLineHeight = null;
// 尾部折叠图标的激活行数阈值
const sideTipRate = 0.25;
let sideTipLineThr = null;
// 图标
const ICON = {
// 三角形顶点向右,可表展开按键
TRIANGLE_RIGHT: `
`,
// 三角形顶点向下,可表折叠按键
TRIANGLE_DOWN: `
`,
// 三角形顶点向上,可表折叠按键
TRIANGLE_UP: `
`,
};
/**
* 枚举所支持的条目类型
*/
const SubjectType = {
// 所支持的类型
ANIME: {en: 'anime', zh: '动画'},
// 待支持的类型
// BOOK: {en: 'book', zh: '书籍'},
// MUSIC: 'music', GAME: 'game', REAL: 'real', CHARACTER: 'character', PERSON: 'person',
getAll(isObj = false) {
if (isObj) return filterEnumValues(this);
else return filterEnumValues(this).map(item => item.en);
},
prase(value) {
if (this.getAll().includes(value)) return value;
return null;
},
// needPrase(value) {
// return value !== this.CHARACTER && value !== this.PERSON;
// },
};
/**
* 枚举各类型条目的功能启用状态
*/
const EnableState = {
// 启用全部功能
ALL_ENABLED: "allEnable",
// 启用部分功能,仅排序不折叠
PARTIAL_ENABLED: "partialEnable",
// 全部功能禁用
ALL_DISABLED: "allDisable",
getAll() {
return filterEnumValues(this);
},
prase(value) {
if (this.getAll().includes(value)) return value;
return null;
},
};
/**
* 管理`localStorage`的键名与初值。
* 键值分为全局配置与各类型条目配置、简单类型与复杂类型
*/
const Key = {
// 键名前缀
_KEY_PREF: 'BangumiStaffSorting',
// 超过此行数的职位信息将被二次折叠
REFOLD_THRESHOLD_KEY: 'refoldThreshold',
REFOLD_THRESHOLD_DEFAULT: 4,
REFOLD_THRESHOLD_DISABLED: 0,
// 各类型条目的功能启用状态
ENABLE_STATE_KEY: 'EnableState',
ENABLE_STATE_DEFAULT: EnableState.ALL_ENABLED,
// 各类型条目的自定义排序与折叠 (复杂类型)
STAFF_MAP_LIST_KEY: 'StaffMapList',
// 当前使用的键值的所属条目类型 (可即时切换)
_subType: null,
makeKey(key, type = null) {
this.setSubType(type);
if (this.isGlobalData(key)) return `${this._KEY_PREF}_${key}`;
else return `${this._KEY_PREF}_${this._subType}${key}`;
},
setSubType(type) {
if (type && SubjectType.getAll().includes(type)) this._subType = type;
},
isComplexData(key) {
return [this.STAFF_MAP_LIST_KEY].includes(key);
},
isGlobalData(key) {
return [this.REFOLD_THRESHOLD_KEY].includes(key);
}
}
/**
* 配置存储,提供`localStorage`的接口。
* 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部
*/
class Store {
// 数据缓存,仅对简单类型的键值
static _cache = {};
// 定义静态防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况)
static debouncedSet;
// 为缺损的配置进行初始化
static initialize() {
if (this.get(Key.REFOLD_THRESHOLD_KEY) === null)
this.set(Key.REFOLD_THRESHOLD_KEY, Key.REFOLD_THRESHOLD_DEFAULT);
SubjectType.getAll().forEach((type) => {
if (this.get(Key.ENABLE_STATE_KEY, type) === null)
this.set(Key.ENABLE_STATE_KEY, Key.ENABLE_STATE_DEFAULT);
});
this._cache = {};
// 动态绑定防抖逻辑,确保 this 指向 Store
this.debouncedSet = debounce(this._set.bind(this));
}
static set(key, value, type = null, isHighFreq = false) {
if (isHighFreq) this.debouncedSet(key, value, type);
else this._set(key, value, type);
}
static _set(key, value, type = null) {
Key.setSubType(type);
const fullKey = Key.makeKey(key);
if (!Key.isComplexData(key)) {
value = JSON.stringify(value);
this._cache[fullKey] = value; // 同步到缓存
}
localStorage.setItem(fullKey, value);
}
static get(key, type = null) {
Key.setSubType(type);
const fullKey = Key.makeKey(key);
// 简单数据类型,命中缓存
if (!Key.isComplexData() && Store._isCacheHit(fullKey)) {
// console.debug(`HIT CHACHE - ${fullKey}: ${this._cache[fullKey]}`);
return this._cache[fullKey];
}
// 无缓存,读取并缓存
const value = localStorage.getItem(fullKey);
if (Key.isComplexData(key)) return value;
const parsedValue = JSON.parse(value);
this._cache[fullKey] = parsedValue;
return parsedValue;
}
static remove(key, type = null) {
Key.setSubType(type);
const fullKey = Key.makeKey(key);
// 同时删除缓存与数据
delete this._cache[fullKey];
localStorage.removeItem(fullKey);
}
static _isCacheHit(fullKey) {
return Object.prototype.hasOwnProperty.call(this._cache, fullKey);
}
}
/**
* `StaffMapList`的`JSON`格式化字符串。
* 最短的有效字符串为`"[]"`,其表示设置空缺。
*/
const StaffMapListJSON = {
/**
* 解析`staffMapListJSON`字符串。
* 用于初步解析与有效性检测,
* 更进一步的解析,将在`StaffMapList`中进行。
* 仅检查:
* 1. 是否满足`JSON`格式
* 2. 是否为数组类型
* 3. 字符串样式的正则表达式,是否满足规定格式
* @returns {Array|null} `StaffMapList`数据或空值
*/
parse(text) {
let parsedData;
try {
parsedData = JSON.parse(text, this._reviver);
} catch (e) {
console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`);
return null;
}
if (!Array.isArray(parsedData)) {
console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数组类型`);
return null;
}
return parsedData;
},
// 将`StaffMapList`转为`JSON`格式化字符串
stringify(data) {
return JSON.stringify(data, this._replacer, 1);
},
// 解析`JSON`字符串中的正则表达式
_reviver(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`格式
_replacer(key, value) {
if (value instanceof RegExp) return value.toString();
return value;
},
}
/**
* 职位的排序列表`jobOrder`与默认折叠的职位`foldableJobs`的合并信息
*/
class StaffMapList {
/**
* 懒加载的默认配置
* 数据基本类型:`
data = [Job | [boolean | Job, ...Job[]]]
Job = string | RegExp
* `其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`False`,需位于子序列的首位才有意义
* (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
*/
static _defaultLazyData = {
[SubjectType.ANIME.en]: () => [,
"中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"],,
"放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/,,
,
"原作", "原案", "人物原案", "原作插图", [true, "原作协力"],,
"团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督",,
"系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/],,
"分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"],,
"人物设定",,
,
"总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/,,
"主动画师", "主要动画师", [true, "构图"], [false, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/,,
"动画检查", [true, /动画检查/],,
,
"设定", "背景设定", "道具设计", /(? [],
};
// 构造函数
constructor(subType) {
// 所属条目类型(不可变更)
this.subType = subType; // 小心 Store._subType 被设置的其他模块切换
// 数据
this.data = [];
// 职位的排序列表
this.jobOrder = [];
// 默认折叠的职位,EnableState = "particalDisable" 时,值为空
this.foldableJobs = [];
// 是否为默认数据
this.isDefault = null;
// 默认配置格式化文本的缓存
this._defaultTextBuffer = null;
}
/**
* 依据`EnableState`进行初始化,使其具备职位匹配的能力。
* 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
*/
initialize() {
Key.setSubType(this.subType);
if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.ALL_DISABLED)
return;
if (!this._loadData()) this._setDefault();
this._resolveData();
}
/**
* 空缺设置,将关闭脚本的职位排序。
* 有两种独立开启途径:
* 1. `EnableState = "allDisable"`
* 2. `StaffMapListJSON = "[]"`
*/
isNull() {
return this.data.length === 0;
}
// 保存自定义的数据
saveData(jsonStr) {
Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
console.log(jsonStr);
console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
}
// 恢复默认数据的设置
resetData() {
Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`);
}
// 使用懒加载恢复默认配置
_setDefault() {
this.isDefault = true;
if (!StaffMapList._defaultLazyData[this.subType])
this.data = []; // 该类型条目未有默认设置
else this.data = StaffMapList._defaultLazyData[this.subType]();
}
// 尝试载入自定义的数据,并作初步解析
_loadData() {
const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
if (!jsonStr) return null; // 键值为空,表示用户启用默认设置
let parsedData = StaffMapListJSON.parse(jsonStr);
if (!parsedData) {
// 通过UI进行的配置一般不可能发生
console.error(
`${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据`
);
return false;
}
/* 修复外层重复嵌套 `[]` 的形式,例如 [["", [true, ""], ""]]
* 同时区分形如 [[true, "", ""]] 此类不需要降维的情形,
* 忽略存在的漏洞:形如 [[true, "", [true, ""], ""]] 将无法降维 */
if (
parsedData.length === 1 &&
Array.isArray(parsedData[0]) &&
typeof parsedData[0][0] !== "boolean"
) {
parsedData = parsedData[0];
}
this.isDefault = false;
this.data = parsedData;
return true;
}
// 完全解析数据,拆解为`jobOrder`与`foldableJobs`
_resolveData() {
this.jobOrder = [];
this.foldableJobs = [];
this.data.forEach((item) => {
if (Array.isArray(item) && item.length) {
// 对数组进行完全展平,提高对非标多层数组的兼容性
item = item.flat(Infinity);
// 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
// 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效
if (typeof item[0] === "boolean") {
// 可以使用 EnableState 仅启用排序,禁用折叠
if (item[0] && Store.get(Key.ENABLE_STATE_KEY, this.subType) ===
EnableState.ALL_ENABLED) {
this.foldableJobs.push(...item.slice(1));
}
this.jobOrder.push(...item.slice(1));
} else {
this.jobOrder.push(...item);
}
} else if (typeof item !== "undefined") {
this.jobOrder.push(item);
}
});
}
/**
* 将数据转化为格式化文本 (有别于`StaffMapListJSON`)
* 用于设置内的显示与编辑,自定义数据与默认数据二者格式化有别
* @returns {string} 格式化文本
*/
formatToText(useDefault) {
let jsonStr = null;
if (!useDefault) {
jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
}
this.isDefault = jsonStr === null;
// 自定义数据
if (jsonStr) return jsonStr.slice(1, -1); // 消除首尾的 `[]`
// 读取缓存的默认数据
else if (this._defaultTextBuffer) return this._defaultTextBuffer;
// 将默认数据转化为格式化文本
this._setDefault();
const text = StaffMapListJSON.stringify(this.data)
.replace(/(null,\n )|(\n\s+)/g, (match, g1, g2) => {
if (g1) return "\n";
if (g2) return " ";
return match;
})
.slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]`
// 使得 `[ `->`[` 同时 ` ]`->`]`
/* const text = StaffMapListJSON.stringify(this.data).replace(
/(null,)|(? {
if (g1) return '\n';
if (g2) return ' ';
if (g3) return '[';
if (g4) return '],';
return match;
}).slice(3, -2); */
this._defaultTextBuffer = text;
return text;
}
}
// 匹配相应 URL 类型的函数入口
const urlPatterns = [
{ type: 'subject', regex: /^\/subject\/\d+$/, handler: handlerSubject },
// { type: 'character', regex: /^\/character\/\d+$/, handler: trySortStaff },
// { type: 'person', regex: /^\/person\/\d+$/, handler: trySortStaff },
{ type: 'settings', regex: /^\/settings\/privacy$/, handler: handlerSettings },
];
function main() {
Store.initialize();
for (const pattern of urlPatterns) {
if (pattern.regex.test(PATHNAME)) {
pattern.handler(pattern.type);
break;
}
}
}
// 处理设置
function handlerSettings() {
const ui = buildSettingUI({ id: 'staff_sorting' });
document.getElementById('columnA').appendChild(ui);
loadSettingStyle();
// 支持 url.hash = ID 进行导引
if (location.hash.slice(1) === 'staff_sorting') {
ui.scrollIntoView({ behavior: 'smooth' });
}
}
// 处理条目
function handlerSubject(subType) {
// if (needPrase(subType))
subType = SubjectType.prase(getSubjectType());
if (!subType) return; // 不支持该类型条目
const ul = document.querySelector('#infobox');
const staffMapList = new StaffMapList(subType);
staffMapList.initialize();
if (!staffMapList.isNull()) {
sortStaff(ul, staffMapList);
} else {
addFoldableTag(ul);
console.log(`${SCRIPT_NAME}:实行网页原有的职位顺序`);
}
loadStaffStyle();
changeExpandToToggleButton(ul);
addRefoldToggleButton(ul);
}
/**
* 巧妙地使用非常便捷的方法,获取当前条目的类型
* 源自 https://bangumi.tv/dev/app/2723/gadget/1242
*/
function getSubjectType() {
const href = document.querySelector("#navMenuNeue .focus").getAttribute("href");
return href.split("/")[1];
}
/**
* 脚本主要逻辑,职位排序并折叠
* @param {HTMLElement} ul - `#infobox`根节点
* @param {StaffMapList} staffMapList
*/
function sortStaff(ul, staffMapList) {
// 职位信息字典
const staffDict = getStaffDict(ul);
// 清空原始的`staff`列表
ul.innerHTML = '';
// 未能匹配职位的待插入位置
let liAfterIntsert = null;
let insterTag = false;
let insertFold = false;
// 按照预定顺序添加到 DOM
staffMapList.jobOrder.forEach(item => {
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 = staffMapList.foldableJobs.includes(item);
} else return
// 4.其余情形均忽略 (且对于意外类型不报错)
} else return;
// 添加职位,并判断是否默认折叠
matchingRoles.forEach(role => {
const li = staffDict[role];
if (typeof item === 'string' && staffMapList.foldableJobs.includes(role)
|| item instanceof RegExp && staffMapList.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 = staffDict[role];
if (insertFold) {
li.classList.add('folded', 'foldable');
if (!hasFolded) hasFolded = true;
}
if (liAfterIntsert) ul.insertBefore(li, liAfterIntsert);
// 未设置待插入位置,则默认插入到末尾,且默认不折叠
else ul.appendChild(li);
});
console.log(
`${SCRIPT_NAME}:未能匹配到的职位`,
(Object.values(staffDict).map(v => `{\n ${v.innerText.trim()}\n}`)).join(',')
);
if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`);
}
/**
* 获取一个字典来存储网页中的职位信息,
* 并对职位信息进行二次折叠
*/
function getStaffDict(ul) {
const staffDict = {};
const lis = ul.querySelectorAll(':scope > li');
lis.forEach(li => {
const tip = li.querySelector('span.tip');
if (!tip) return;
const role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
// 为了正确计算元素高度,需使其 display
li.classList.remove('folded');
refoldStaff(li, tip);
staffDict[role] = li;
// li.folded 属性已经失效无需还原
});
return staffDict;
}
/**
* 为网页原有的`folded`类别添加`foldable`便签,用于实现切换,
* 并对职位信息进行二次折叠
*/
function addFoldableTag(ul) {
const lis = ul.querySelectorAll(':scope > li');
lis.forEach(li => {
let flag = li.classList.contains('folded');
if (flag) {
if (!hasFolded) hasFolded = true;
// 为了正确计算元素高度,需先使其 display
li.classList.remove('folded');
}
const tip = li.querySelector('span.tip');
if (tip) refoldStaff(li, tip);
/* 特殊用法 StaffMapListJSON = "[]" 同时 EnableState = "partialDisable"
* 将实行网页原有的职位顺序,同时禁止其折叠 */
if (flag && Store.get(Key.ENABLE_STATE_KEY) !== EnableState.PARTIAL_ENABLED)
li.classList.add('folded', 'foldable');
});
if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.PARTIAL_ENABLED)
hasFolded = false;
}
/**
* 对超出限制行数的职位信息进行二次折叠,并添加开关。
* 实现动态不定摘要的类似于`summary`的功能。
* 过滤`别名`等不定行高的`infobox`信息
*/
function refoldStaff(li, tip) {
if (Store.get(Key.REFOLD_THRESHOLD_KEY) === Key.REFOLD_THRESHOLD_DISABLED) return;
if (li.classList.contains('sub_container')) return; // 不定行高的 infobox 信息
const lineCnt = getLineCnt(li);
const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
if (lineCnt <= refoldThr) return;
// 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
// 尝试不修改 DOM 结构仅通过样式添加折叠效果,但未果,故改为内嵌一层新的 div 元素
// 添加头部开关状态图标
const prefIcon = createElement('i');
prefIcon.innerHTML = ICON.TRIANGLE_RIGHT;
/* 尝试使用