// ==UserScript==
// @name Bangumi Ultimate Enhancer
// @namespace https://tampermonkey.net/
// @version 2.5.3
// @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() {
// 排除特定编辑页面
const EXCLUDE_PATHS = /(edit_detail|edit|add_related|upload_img)/;
if (EXCLUDE_PATHS.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, pageId] = pathMatch;
const origin = location.origin;
// 按钮配置
const buttons = [
{
className: "wiki-button",
getText: () => "Wiki",
getUrl: () => pageType === "subject"
? `${origin}/${pageType}/${pageId}/edit_detail`
: `${origin}/${pageType}/${pageId}/edit`
},
{
className: "relate-button",
getText: () => "关联",
getUrl: () => pageType === "subject"
? `${origin}/${pageType}/${pageId}/add_related/subject/anime`
: `${origin}/${pageType}/${pageId}/add_related/anime`
}
];
// 添加按钮
buttons.forEach(button => {
if (!nav.querySelector(`.${button.className}`)) {
const li = document.createElement("li");
li.className = button.className;
li.innerHTML = `${button.getText()}`;
nav.appendChild(li);
}
});
}
// 监听 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() {
// 支持的域名和需要排除的路径
const SUPPORTED_DOMAINS = ['bangumi\\.tv', 'bgm\\.tv', 'chii\\.in'];
const EXCLUDE_PATHS = /\/(edit_detail|edit|add_related|upload_img)$/;
// 如果是编辑页面,则不执行
if (EXCLUDE_PATHS.test(location.pathname)) return;
// 解析页面类型和 ID
const url = window.location.href;
const parseId = (path) => {
const regex = new RegExp(`(${SUPPORTED_DOMAINS.join('|')})\\/${path}\\/(\\d+)`);
const match = url.match(regex);
return match ? { id: match[2], type: path } : null;
};
const typeMapping = ['subject', 'person', 'character'];
const parsedInfo = typeMapping.reduce((result, type) => result || parseId(type), null);
if (!parsedInfo) return;
// 避免重复添加按钮
if (document.querySelector("#coverUploadButton")) return;
// 获取导航栏(兼容多个模板)
const nav = document.querySelector(".subjectNav .navTabs") || document.querySelector(".navTabs");
if (!nav) return;
// 创建上传按钮(保持原有UI设计)
const createUploadButton = () => {
const uploadLi = document.createElement("li");
uploadLi.id = "coverUploadButton";
uploadLi.className = "upload-button";
uploadLi.style.float = "right";
uploadLi.innerHTML = `上传封面`;
return uploadLi;
};
// 创建表单容器,增加状态提示区域
const createFormContainer = () => {
const formContainer = document.createElement("div");
formContainer.id = "coverUploadFormContainer";
formContainer.classList.add("cover-upload-modal");
// 增加状态提示区域
formContainer.innerHTML = `
`;
// 设置必要的定位和初始隐藏
formContainer.style.position = "absolute";
formContainer.style.zIndex = "9999";
formContainer.style.display = "none";
return formContainer;
};
const uploadLi = createUploadButton();
const formContainer = createFormContainer();
nav.appendChild(uploadLi);
document.body.appendChild(formContainer);
let formLoaded = false; // 标记本地上传表单是否加载完毕
let hideTimeout = null;
// 显示状态消息
function showStatus(message, isError = false) {
const statusDiv = document.getElementById('statusMessage');
statusDiv.textContent = message;
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = isError ? '#ffeeee' : '#eeffee';
statusDiv.style.color = isError ? '#cc0000' : '#007700';
statusDiv.style.border = `1px solid ${isError ? '#cc0000' : '#007700'}`;
console.log(`[状态] ${message}`);
}
// 创建一个隐藏的iframe用于POST请求
function createHiddenIframe() {
const existingIframe = document.getElementById('hiddenUploadFrame');
if (existingIframe) {
return existingIframe;
}
const iframe = document.createElement('iframe');
iframe.id = 'hiddenUploadFrame';
iframe.name = 'hiddenUploadFrame';
iframe.style.display = 'none';
document.body.appendChild(iframe);
return iframe;
}
// 从上传结果页面中提取投票链接并自动投票
function processUploadResult(iframe) {
try {
// 等待iframe加载完成
return new Promise((resolve, reject) => {
iframe.onload = function() {
try {
// 获取iframe中的文档内容
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
console.log("Iframe加载完成,检查投票链接...");
// 获取所有投票链接
const allVoteLinks = iframeDocument.querySelectorAll('a[href*="/vote/cover/"]');
console.log(`找到 ${allVoteLinks.length} 个投票链接`);
// 选择最后一个投票链接
const voteLink = allVoteLinks.length > 0 ? allVoteLinks[allVoteLinks.length - 1] : null;
if (voteLink) {
const href = voteLink.getAttribute('href');
console.log("选择最后一个投票链接:", href);
// 构建完整的投票URL
const host = window.location.host;
const voteUrl = href.startsWith('http') ? href : `https://${host}${href.startsWith('/') ? '' : '/'}${href}`;
console.log("完整投票URL:", voteUrl);
// 使用fetch API进行投票
showStatus('封面上传成功,正在投票...');
fetch(voteUrl, {
method: 'GET',
credentials: 'include', // 重要:包含cookies
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml',
'Referer': window.location.href
}
})
.then(response => {
if (response.ok) {
console.log("投票成功,状态:", response.status);
showStatus('投票成功!页面将在3秒后刷新...');
setTimeout(() => window.location.reload(), 3000);
resolve(true);
} else {
console.error("投票失败,状态:", response.status);
showStatus('封面上传成功,但投票失败。请手动完成投票。', true);
reject(new Error(`投票请求失败,状态 ${response.status}`));
}
})
.catch(error => {
console.error("投票请求错误:", error);
showStatus('封面上传成功,但投票失败。请手动完成投票。', true);
reject(error);
});
} else {
// 检查是否有错误信息
console.log("未找到投票链接,检查是否有错误消息");
const errorMsg = iframeDocument.querySelector('.error, .errorMessage, [class*="error"]');
if (errorMsg) {
console.error("上传错误:", errorMsg.textContent);
showStatus(`上传失败: ${errorMsg.textContent}`, true);
reject(new Error(errorMsg.textContent));
} else {
console.log("未找到投票链接或错误消息");
showStatus('封面似乎已上传成功,但未找到投票链接。请手动检查。', true);
reject(new Error('未找到投票链接'));
}
}
} catch (error) {
console.error('处理上传结果时出错:', error);
showStatus('处理上传结果时出错', true);
reject(error);
}
};
// 处理iframe加载错误
iframe.onerror = function(error) {
console.error("Iframe加载错误:", error);
showStatus('上传请求失败', true);
reject(new Error('上传请求失败'));
};
});
} catch (error) {
console.error('处理上传结果时出错:', error);
showStatus(`处理出错: ${error.message}`, true);
return Promise.reject(error);
}
}
// 修改表单提交处理函数,使用iframe提交
function setupFormForIframeSubmission(form) {
// 创建隐藏的iframe
const iframe = createHiddenIframe();
// 设置表单的target为iframe
form.target = 'hiddenUploadFrame';
// 监听表单提交事件
form.addEventListener('submit', function(e) {
// 不阻止默认提交行为,而是让它提交到iframe
showStatus('正在上传封面...');
// 处理iframe的响应
processUploadResult(iframe).catch(error => {
console.error('处理上传结果失败:', error);
});
});
}
// 图片下载和转换函数
async function downloadAndConvertImage(imageUrl) {
try {
// 尝试提取实际的图片 URL
let actualImageUrl = imageUrl;
// 检查是否是 Google 图片重定向链接
if (imageUrl.includes('google.com/imgres')) {
const urlParams = new URL(imageUrl).searchParams;
actualImageUrl = urlParams.get('imgurl');
}
// 如果没有提取到图片 URL,则使用原始链接
if (!actualImageUrl) {
actualImageUrl = imageUrl;
}
// 显示正在下载的状态
showStatus('正在下载图片...');
// 发送请求下载图片
const response = await fetch(actualImageUrl);
const blob = await response.blob();
// 根据输入图片的类型确定输出格式
const inputType = blob.type;
const outputType = inputType.includes('jpeg') || inputType.includes('jpg')
? 'image/jpeg'
: inputType.includes('png')
? 'image/png'
: 'image/jpeg'; // 如果类型未知,默认转换为 JPEG
// 创建画布用于图像转换
const img = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 将图像转换为指定格式的 Blob
const convertedBlob = await new Promise(resolve => {
canvas.toBlob(resolve, outputType);
});
// 根据转换后的格式确定文件扩展名
const fileExtension = outputType === 'image/png' ? 'png' : 'jpg';
const convertedFile = new File([convertedBlob], `cover.${fileExtension}`, { type: outputType });
// 预览转换后的图片
const previewContainer = document.querySelector("#imagePreviewContainer");
const previewImage = document.querySelector("#imagePreview");
previewImage.src = URL.createObjectURL(convertedBlob);
previewContainer.style.display = "block";
// 查找文件上传表单
const fileInput = document.querySelector("#coverUploadForm input[type='file']");
if (fileInput) {
// 创建 DataTransfer 对象并填充文件
const dataTransfer = new DataTransfer();
dataTransfer.items.add(convertedFile);
fileInput.files = dataTransfer.files;
// 触发文件上传输入框的 change 事件
const event = new Event('change', { bubbles: true });
fileInput.dispatchEvent(event);
// 显示提交按钮
const submitButton = document.querySelector("#coverUploadForm input[type='submit']");
if (submitButton) {
submitButton.style.display = 'block';
}
showStatus('图片已准备好,点击提交按钮上传');
} else {
showStatus('未找到文件上传输入框', true);
}
} catch (error) {
console.error('下载或转换图片时发生错误:', error);
showStatus(`下载图片失败:${error.message}`, true);
}
}
// 全局点击事件,点击表单容器外区域关闭表单
function setupGlobalClickHandler(container, trigger) {
document.addEventListener('click', function (event) {
if (!container.contains(event.target) && !trigger.contains(event.target)) {
container.style.display = "none";
}
});
}
// 预先加载本地上传表单,提升加载速度
async function preloadLocalUpload() {
if (formLoaded) return;
const uploadFormContainer = formContainer.querySelector("#uploadFormContainer");
uploadFormContainer.innerHTML = "加载中...";
try {
const uploadUrl = `https://${window.location.host}/${parsedInfo.type}/${parsedInfo.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) {
form.id = "coverUploadForm";
form.style.margin = "0";
form.style.padding = "0";
// 配置表单使用iframe提交
uploadFormContainer.innerHTML = form.outerHTML;
// 获取插入到DOM中的表单元素并设置
const insertedForm = document.getElementById("coverUploadForm");
setupFormForIframeSubmission(insertedForm);
// 为本地文件上传绑定预览处理事件
const fileInput = document.querySelector("#coverUploadForm input[type='file']");
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
const previewContainer = formContainer.querySelector("#imagePreviewContainer");
const previewImage = formContainer.querySelector("#imagePreview");
previewImage.src = ev.target.result;
previewContainer.style.display = "block";
const submitButton = document.querySelector("#coverUploadForm input[type='submit']");
if (submitButton) {
submitButton.style.display = 'block';
}
showStatus('图片已准备好,点击提交按钮上传');
};
reader.readAsDataURL(file);
}
});
formLoaded = true;
} else {
uploadFormContainer.innerHTML = "无法加载上传表单";
showStatus("无法加载上传表单", true);
}
} catch (e) {
uploadFormContainer.innerHTML = "加载失败";
showStatus("加载上传表单失败", true);
console.error("上传模块加载失败:", e);
}
}
// 事件绑定逻辑,保持原有UI展示行为
const setupEventHandlers = () => {
const urlInput = formContainer.querySelector("#imageUrlInput");
const downloadButton = formContainer.querySelector("#downloadUrlButton");
// 显示表单函数
const showForm = () => {
clearTimeout(hideTimeout);
const buttonRect = uploadLi.getBoundingClientRect();
formContainer.style.top = `${buttonRect.bottom + window.scrollY + 5}px`;
formContainer.style.left = `${buttonRect.left + window.scrollX - 180}px`;
formContainer.style.display = "block";
};
// 延迟隐藏表单逻辑(在有预览时不自动隐藏)
const hideForm = () => {
const previewContainer = formContainer.querySelector("#imagePreviewContainer");
const statusMessage = formContainer.querySelector("#statusMessage");
if (previewContainer.style.display === "block" || statusMessage.style.display === "block") return;
hideTimeout = setTimeout(() => {
if (!formContainer.matches(":hover")) {
formContainer.style.display = "none";
}
}, 200);
};
uploadLi.addEventListener("mouseenter", showForm);
uploadLi.addEventListener("mouseleave", hideForm);
formContainer.addEventListener("mouseenter", () => clearTimeout(hideTimeout));
formContainer.addEventListener("mouseleave", hideForm);
// URL上传部分事件处理
urlInput.addEventListener('focus', () => {
urlInput.style.borderColor = '#F4C7CC';
urlInput.style.boxShadow = '0 0 5px rgba(244, 199, 204, 0.5)';
});
urlInput.addEventListener('blur', () => {
urlInput.style.borderColor = '#ddd';
urlInput.style.boxShadow = 'none';
});
downloadButton.addEventListener('click', () => {
const imageUrl = urlInput.value.trim();
if (imageUrl) {
downloadAndConvertImage(imageUrl);
} else {
showStatus('请输入图片 URL', true);
}
});
urlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') downloadButton.click();
});
};
// 注册全局点击事件(点击容器外关闭表单)
setupGlobalClickHandler(formContainer, uploadLi);
// 预先加载本地上传表单(在页面初始加载时即启动)
preloadLocalUpload();
// 事件处理绑定
setupEventHandlers();
}
/* MutationObserver 保证上传按钮始终存在 */
const observer = new MutationObserver(() => {
if (!document.querySelector("#coverUploadButton")) {
initCoverUpload();
}
});
observer.observe(document.body, { childList: true, subtree: true });
/* ====================
批量分集编辑器功能模块
=====================*/
const BatchEpisodeEditor = {
// 常量配置
CHUNK_SIZE: 20,
BASE_URL: '',
CSRF_TOKEN: '',
// 初始化入口
init() {
// 非分集编辑页面直接返回
if (!this.isEpisodePage()) return;
// 获取基础URL和CSRF令牌
this.BASE_URL = location.pathname.replace(/\/edit_batch$/, '');
this.CSRF_TOKEN = $('[name=formhash]')?.value || '';
if (!this.CSRF_TOKEN) return;
// 绑定事件和增强功能
this.bindHashChange();
this.upgradeCheckboxes();
this.addEnhancerNotice();
},
// 添加功能增强提示
addEnhancerNotice() {
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: () => /^\/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() {
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({
chkall: 'on',
submit: '批量修改',
formhash: this.CSRF_TOKEN,
'ep_mod[]': episodeIds
});
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(/