// ==UserScript==
// @name Bangumi Ultimate Enhancer
// @namespace https://tampermonkey.net/
// @version 2.4.2
// @description Bangumi 终极增强套件 - 集成Wiki按钮、关联按钮、封面上传、批量关联、批量分集编辑等功能
// @author Bios (improved by Claude)
// @match *://bgm.tv/subject/*
// @match *://chii.in/subject/*
// @match *://bangumi.tv/subject*
// @match *://bgm.tv/character/*
// @match *://chii.in/character/*
// @match *://bangumi.tv/character/*
// @match *://bgm.tv/person/*
// @match *://chii.in/person/*
// @match *://bangumi.tv/person/*
// @exclude */character/*/add_related/person*
// @exclude */person/*/add_related/character*
// @connect bgm.tv
// @icon https://lain.bgm.tv/pic/icon/l/000/00/01/128.jpg
// @grant GM_xmlhttpRequest
// @license MIT
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @run-at document-idle
// @downloadURL none
// ==/UserScript==
(function() {
"use strict";
// 样式注入
function injectStyles() {
$('head').append(`
`);
}
/* ====================
Wiki 按钮和关联按钮模块
======================*/
function initNavButtons() {
// 排除特定页面
if (/(edit_detail|edit|add_related|upload_img)/.test(location.pathname)) return;
// 获取导航栏
const nav = document.querySelector(".subjectNav .navTabs, .navTabs");
if (!nav) return;
// 匹配页面类型和ID
const pathMatch = location.pathname.match(/\/(subject|person|character)\/(\d+)/);
if (!pathMatch) return;
const pageType = pathMatch[1]; // subject, person, or character
const pageId = pathMatch[2]; // ID number
// 添加Wiki按钮
if (!nav.querySelector(".wiki-button")) {
const wikiUrl = pageType === "subject"
? `${location.origin}/${pageType}/${pageId}/edit_detail`
: `${location.origin}/${pageType}/${pageId}/edit`;
const wikiLi = document.createElement("li");
wikiLi.className = "wiki-button";
wikiLi.innerHTML = `Wiki`;
nav.appendChild(wikiLi);
}
// 添加关联按钮
if (!nav.querySelector(".relate-button")) {
const relateUrl = pageType === "subject"
? `${location.origin}/${pageType}/${pageId}/add_related/subject/anime`
: `${location.origin}/${pageType}/${pageId}/add_related/anime`;
const relateLi = document.createElement("li");
relateLi.className = "relate-button";
relateLi.innerHTML = `关联`;
nav.appendChild(relateLi);
}
}
// 监听 URL 变化
function observeURLChanges() {
let lastURL = location.href;
new MutationObserver(() => {
if (location.href !== lastURL) {
lastURL = location.href;
initNavButtons();
}
}).observe(document, { subtree: true, childList: true });
}
/* =========
封面上传模块
===========*/
async function initCoverUpload() {
if (/\/(edit_detail|edit|add_related|upload_img)/.test(location.pathname)) return;
const url = window.location.href;
let id, type;
if (/bangumi\.tv\/subject\/(\d+)/.test(url)) {
id = url.match(/bangumi\.tv\/subject\/(\d+)/)[1];
type = "subject";
} else if (/bangumi\.tv\/person\/(\d+)/.test(url)) {
id = url.match(/bangumi\.tv\/person\/(\d+)/)[1];
type = "person";
} else if (/bangumi\.tv\/character\/(\d+)/.test(url)) {
id = url.match(/bangumi\.tv\/character\/(\d+)/)[1];
type = "character";
} else {
return;
}
// 检查按钮是否已存在,避免重复插入
if (document.querySelector("#coverUploadButton")) return;
// 获取导航栏
const nav = document.querySelector(".subjectNav .navTabs") || document.querySelector(".navTabs");
if (!nav) return;
// 创建上传按钮
const uploadLi = document.createElement("li");
uploadLi.id = "coverUploadButton";
uploadLi.className = "upload-button";
uploadLi.style.float = "right";
uploadLi.innerHTML = `上传封面`;
nav.appendChild(uploadLi);
// 创建上传表单容器(初始隐藏)
const formContainer = document.createElement("div");
formContainer.id = "coverUploadFormContainer";
formContainer.style.display = "none";
formContainer.style.position = "absolute";
formContainer.style.zIndex = "1000";
formContainer.style.backgroundColor = "#fff";
formContainer.style.border = "1px solid #ddd";
formContainer.style.borderRadius = "4px";
formContainer.style.padding = "10px";
formContainer.style.boxShadow = "0 2px 5px rgba(0,0,0,0.2)";
formContainer.style.width = "240px";
document.body.appendChild(formContainer);
let formLoaded = false;
let hideTimeout = null;
uploadLi.addEventListener("mouseenter", async function () {
clearTimeout(hideTimeout);
// 定位表单
const buttonRect = uploadLi.getBoundingClientRect();
formContainer.style.top = (buttonRect.bottom + window.scrollY) + "px";
// 让表单的左侧与“封面”按钮的左侧对齐
formContainer.style.left = (buttonRect.left + window.scrollX - 180) + "px";
formContainer.style.display = "block";
if (!formLoaded) {
formContainer.innerHTML = "加载中...";
try {
const uploadUrl = `https://bangumi.tv/${type}/${id}/upload_img`;
const res = await fetch(uploadUrl);
const doc = new DOMParser().parseFromString(await res.text(), "text/html");
const form = doc.querySelector("form[enctype='multipart/form-data']");
if (form) {
formContainer.innerHTML = "";
const clone = form.cloneNode(true);
clone.id = "coverUploadForm";
formContainer.appendChild(clone);
formLoaded = true;
} else {
formContainer.innerHTML = "无法加载上传表单";
}
} catch (e) {
formContainer.innerHTML = "加载失败";
console.error("上传模块加载失败:", e);
}
}
});
uploadLi.addEventListener("mouseleave", function () {
hideTimeout = setTimeout(() => {
if (!formContainer.matches(":hover")) {
formContainer.style.display = "none";
}
}, 200);
});
formContainer.addEventListener("mouseenter", function () {
clearTimeout(hideTimeout);
});
formContainer.addEventListener("mouseleave", function () {
hideTimeout = setTimeout(() => {
formContainer.style.display = "none";
}, 200);
});
}
// 监听整个页面的变化,确保按钮不会消失
const observer = new MutationObserver(() => {
if (!document.querySelector("#coverUploadButton")) {
initCoverUpload();
}
});
// 监听整个 body,防止 Bangumi 的 SPA 机制导致按钮被删除
observer.observe(document.body, { childList: true, subtree: true });
// 初次执行
document.addEventListener("DOMContentLoaded", initCoverUpload);
setTimeout(initCoverUpload, 1000);
/* ====================
批量分集编辑器功能模块
=====================*/
const BatchEpisodeEditor = {
CHUNK_SIZE: 20,
BASE_URL: '',
CSRF_TOKEN: '',
// 初始化方法
init() {
if (!this.isEpisodePage()) return;
this.BASE_URL = location.pathname.replace(/\/edit_batch$/, '');
this.CSRF_TOKEN = $('[name=formhash]')?.value || '';
if (!this.CSRF_TOKEN) return;
this.bindHashChange();
this.upgradeCheckboxes();
// 添加功能标识
const header = document.querySelector('h2.subtitle');
if (header) {
const notice = document.createElement('div');
notice.className = 'bgm-enhancer-status';
notice.textContent = '已启用分批编辑功能,支持超过20集的批量编辑';
header.parentNode.insertBefore(notice, header.nextSibling);
}
},
// 检查是否为分集页面
isEpisodePage() {
return /^\/subject\/\d+\/ep(\/edit_batch)?$/.test(location.pathname);
},
// 监听hash变化处理批量编辑
bindHashChange() {
const processHash = () => {
const ids = this.getSelectedIdsFromHash();
if (ids.length > 0) this.handleBatchEdit(ids);
};
window.addEventListener('hashchange', processHash);
if (location.hash.includes('episodes=')) processHash();
},
// 增强复选框功能
upgradeCheckboxes() {
// 动态更新表单action
const updateFormAction = () => {
const ids = $$('[name="ep_mod[]"]:checked').map(el => el.value);
$('form[name="edit_ep_batch"]').action =
`${this.BASE_URL}/edit_batch#episodes=${ids.join(',')}`;
};
$$('[name="ep_mod[]"]').forEach(el =>
el.addEventListener('change', updateFormAction)
);
// 全选功能
$('[name=chkall]')?.addEventListener('click', () => {
$$('[name="ep_mod[]"]').forEach(el => el.checked = true);
updateFormAction();
});
},
// 从hash获取选中ID
getSelectedIdsFromHash() {
const match = location.hash.match(/episodes=([\d,]+)/);
return match ? match[1].split(',').filter(Boolean) : [];
},
// 批量编辑主逻辑
async handleBatchEdit(episodeIds) {
try {
// 分块加载数据
const chunks = this.createChunks(episodeIds, this.CHUNK_SIZE);
const dataChunks = await this.loadChunkedData(chunks);
// 填充表单数据
$('#summary').value = dataChunks.flat().join('\n');
$('[name=ep_ids]').value = episodeIds.join(',');
// 增强表单提交
this.upgradeFormSubmit(chunks, episodeIds);
window.chiiLib?.ukagaka?.presentSpeech('数据加载完成');
} catch (err) {
console.error('批量处理失败:', err);
alert('数据加载失败,请刷新重试');
}
},
// 分块加载数据
async loadChunkedData(chunks) {
window.chiiLib?.ukagaka?.presentSpeech('正在加载分集数据...');
return Promise.all(chunks.map(chunk =>
this.fetchChunkData(chunk).then(data => data.split('\n'))
));
},
// 获取单块数据
async fetchChunkData(episodeIds) {
const params = new URLSearchParams();
params.append('chkall', 'on');
params.append('submit', '批量修改');
params.append('formhash', this.CSRF_TOKEN);
episodeIds.forEach(id => params.append('ep_mod[]', id));
const res = await fetch(`${this.BASE_URL}/edit_batch`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
});
const html = await res.text();
const match = html.match(/