// ==UserScript== // @name Bangumi Ultimate Enhancer // @namespace https://tampermonkey.net/ // @version 2.5.4 // @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 convertImageFormat(file) { return new Promise((resolve, reject) => { try { // 读取文件 const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { // 检查图片类型并自动决定最佳格式 const fileType = file.type.toLowerCase(); let hasTransparency = false; let finalFormat; // 创建画布检测透明度 const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); // 检查图片是否包含透明像素 try { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const pixels = imageData.data; // 检查透明度通道 for (let i = 3; i < pixels.length; i += 4) { if (pixels[i] < 255) { hasTransparency = true; break; } } } catch (e) { console.warn("无法检查透明度,默认使用原格式", e); } // 决定最终格式 if (hasTransparency) { // 有透明度,使用PNG finalFormat = 'image/png'; console.log("检测到透明像素,使用PNG格式"); } else if (fileType.includes('png') && !hasTransparency) { // 无透明度的PNG图片转为JPEG以减小体积 finalFormat = 'image/jpeg'; console.log("PNG图片无透明像素,转换为JPEG格式以优化体积"); } else { // 保持原格式 finalFormat = fileType.includes('jpeg') || fileType.includes('jpg') ? 'image/jpeg' : fileType.includes('png') ? 'image/png' : 'image/jpeg'; // 默认JPEG console.log(`保持原始格式: ${finalFormat}`); } // 重新创建画布以适应格式 canvas.width = img.width; canvas.height = img.height; const newCtx = canvas.getContext('2d'); // 如果是JPEG,先填充白色背景 if (finalFormat === 'image/jpeg') { newCtx.fillStyle = '#FFFFFF'; newCtx.fillRect(0, 0, canvas.width, canvas.height); } // 绘制图片 newCtx.drawImage(img, 0, 0); // 转换为指定格式 const quality = finalFormat === 'image/jpeg' ? 0.92 : undefined; // JPEG质量设置 canvas.toBlob((blob) => { if (!blob) { reject(new Error('转换图片失败')); return; } // 确定文件扩展名 const ext = finalFormat === 'image/png' ? 'png' : 'jpg'; const newFileName = file.name.split('.')[0] + '.' + ext; // 创建新文件对象 const convertedFile = new File([blob], newFileName, { type: finalFormat }); console.log(`图片已转换为 ${finalFormat}`); resolve({ file: convertedFile, dataURL: canvas.toDataURL(finalFormat, quality), format: finalFormat.split('/')[1] }); }, finalFormat, quality); }; img.onerror = () => { reject(new Error('加载图片失败')); }; img.src = e.target.result; }; reader.onerror = () => { reject(new Error('读取文件失败')); }; reader.readAsDataURL(file); } catch (error) { reject(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 tempFileName = actualImageUrl.split('/').pop() || 'image'; const tempFile = new File([blob], tempFileName, { type: blob.type }); // 使用自动转换函数 showStatus('正在优化图片格式...'); const convertedData = await convertImageFormat(tempFile); // 预览转换后的图片 const previewContainer = document.querySelector("#imagePreviewContainer"); const previewImage = document.querySelector("#imagePreview"); previewImage.src = convertedData.dataURL; previewContainer.style.display = "block"; // 查找文件上传表单 const fileInput = document.querySelector("#coverUploadForm input[type='file']"); if (fileInput) { // 创建 DataTransfer 对象并填充文件 const dataTransfer = new DataTransfer(); dataTransfer.items.add(convertedData.file); 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(`图片已优化为 ${convertedData.format.toUpperCase()} 格式,点击提交按钮上传`); } 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', async (e) => { const file = e.target.files[0]; if (file) { try { // 显示正在处理的状态 showStatus('正在处理图片...'); // 使用自动转换函数处理图片 const convertedData = await convertImageFormat(file); // 更新文件输入框的文件 const dataTransfer = new DataTransfer(); dataTransfer.items.add(convertedData.file); fileInput.files = dataTransfer.files; // 更新预览 const previewContainer = formContainer.querySelector("#imagePreviewContainer"); const previewImage = formContainer.querySelector("#imagePreview"); previewImage.src = convertedData.dataURL; previewContainer.style.display = "block"; // 显示提交按钮 const submitButton = document.querySelector("#coverUploadForm input[type='submit']"); if (submitButton) { submitButton.style.display = 'block'; } showStatus(`图片已优化为 ${convertedData.format.toUpperCase()} 格式,点击提交按钮上传`); } catch (error) { console.error('处理本地图片失败:', error); showStatus(`处理图片失败: ${error.message}`, true); // 如果转换失败,回退到原始文件的简单预览 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"; 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(/
添加进度:0/0
未找到的${uiTitle}:
存在多结果的${uiTitle}(无最佳匹配结果,将自动选择第一个):
`); // 添加关联类型选择器 $('.Relation_item_type').append(generateTypeSelector()); $('.Relation_item_type select').prepend('').val('-999'); // 绑定事件 $('#btn_ctd_multi_search').on('click', Relation_MultiFindItemFunc); $('.Relation_item_type select').on('change', function() { globalItemType = $(this).val(); }); $('.tab-nav button').on('click', function() { switchTab($(this).data('tab')); }); } // 启动所有功能 function startEnhancer() { initNavButtons(); observeURLChanges(); initCoverUpload(); initBatchRelation() BatchEpisodeEditor.init(); console.log("Bangumi Ultimate Enhancer 已启动"); } // 在DOM加载完成后启动脚本 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startEnhancer); } else { startEnhancer(); } })();