🏠 Home 

Bangumi Ultimate Enhancer

Bangumi 终极增强套件 - 集成Wiki按钮、关联按钮、封面上传、批量关联、批量分集编辑等功能


Install this script?
  1. // ==UserScript==
  2. // @name Bangumi Ultimate Enhancer
  3. // @namespace https://tampermonkey.net/
  4. // @version 2.4
  5. // @description Bangumi 终极增强套件 - 集成Wiki按钮、关联按钮、封面上传、批量关联、批量分集编辑等功能
  6. // @author Bios (improved by Claude)
  7. // @match *://bgm.tv/subject/*
  8. // @match *://chii.in/subject/*
  9. // @match *://bangumi.tv/subject*
  10. // @match *://bgm.tv/character/*
  11. // @match *://chii.in/character/*
  12. // @match *://bangumi.tv/character/*
  13. // @match *://bgm.tv/person/*
  14. // @match *://chii.in/person/*
  15. // @match *://bangumi.tv/person/*
  16. // @connect bgm.tv
  17. // @grant GM_xmlhttpRequest
  18. // @license MIT
  19. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  20. // @run-at document-idle
  21. // ==/UserScript==
  22. (function() {
  23. "use strict";
  24. // 在页面加载完成后执行
  25. $(document).ready(function() {
  26. // 判断当前是否在关联页面
  27. const isRelatedPage = window.location.href.indexOf('/add_related') !== -1;
  28. // 判断当前是否在条目/角色/人物主页
  29. const isMainPage = /\/(subject|character|person)\/\d+$/.test(window.location.pathname);
  30. // 根据页面类型选择初始化不同功能
  31. if (isRelatedPage) {
  32. initBatchRelation();
  33. } else if (isMainPage) {
  34. initIDCollector();
  35. }
  36. });
  37. // 样式注入
  38. function injectStyles() {
  39. $('head').append(`
  40. <style>
  41. /* 通用按钮美化 */
  42. .btnCustom {
  43. margin: 5px 0;
  44. background-color: #1E90FF !important;
  45. color: white !important;
  46. border-radius: 5px !important;
  47. padding: 6px 18px !important;
  48. border: none !important;
  49. cursor: pointer !important;
  50. position: relative;
  51. top: -4px;
  52. left: -8px;
  53. font-size: 14px;
  54. font-weight: bold;
  55. text-align: center;
  56. display: flex;
  57. justify-content: flex-end;
  58. align-items: center;
  59. transition: opacity 0.2s, transform 0.2s;
  60. }
  61. .btnCustom:hover {
  62. opacity: 0.9;
  63. box-shadow: 3px 6px 12px rgba(0, 0, 0, 0.15);
  64. }
  65. /* 输入框优化 */
  66. .enhancer-textarea {
  67. width: 100%;
  68. min-height: 80px;
  69. max-height: 300px;
  70. border: 1px solid #ccc;
  71. border-radius: 12px;
  72. padding: 10px;
  73. margin: 10px 0;
  74. resize: vertical;
  75. font-size: 14px;
  76. box-sizing: border-box;
  77. background: #fdfdfd;
  78. transition: all 0.05s ease-in-out;
  79. }
  80. .enhancer-textarea:focus {
  81. border-color: #1E90FF;
  82. box-shadow: 0 0 8px rgba(30, 144, 255, 0.3);
  83. outline: none;
  84. }
  85. /* 卡片式面板美化 */
  86. .enhancer-panel {
  87. margin: 10px 0;
  88. border-radius: 10px;
  89. padding: 12px;
  90. background: #ffffff;
  91. border: 1px solid #e0e0e0;
  92. box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.05);
  93. }
  94. .enhancer-panel h3 {
  95. margin-top: 0;
  96. margin-bottom: 10px;
  97. font-size: 15px;
  98. color: #333;
  99. }
  100. /* 标签和选择框美化 */
  101. .select-label {
  102. margin-right: 8px;
  103. font-weight: bold;
  104. color: #555;
  105. }
  106. .chitanda_item_type {
  107. display: flex;
  108. margin-right: auto;
  109. align-items: center;
  110. margin-bottom: 8px;
  111. }
  112. /* 主要容器美化 */
  113. .chitanda_wrapper {
  114. margin: 15px 0;
  115. background: #fff;
  116. padding: 15px;
  117. border-radius: 10px;
  118. border: 1px solid #eaeaea;
  119. box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.05);
  120. }
  121. /* 进度提示美化 */
  122. .chitanda_progress {
  123. margin: 12px 0;
  124. color: #d346bb;
  125. font-weight: bold;
  126. font-size: 18px;
  127. text-align: center;
  128. }
  129. /* 信息提示框优化 */
  130. .chitanda_item_not_found, .chitanda_item_dupe {
  131. margin-top: 8px;
  132. padding: 8px;
  133. min-height: 15px;
  134. border-radius: 6px;
  135. font-size: 14px;
  136. }
  137. .chitanda_item_not_found {
  138. color: #e74c3c;
  139. background: #ffebeb;
  140. border: 1px solid #e74c3c;
  141. }
  142. .chitanda_item_dupe {
  143. color: #3498db;
  144. background: #eaf6ff;
  145. border: 1px solid #3498db;
  146. }
  147. /* 标题优化 */
  148. .chitanda_header {
  149. font-size: 16px;
  150. margin: 12px 0 6px;
  151. color: #333;
  152. font-weight: bold;
  153. }
  154. /* 按钮区域对齐优化 */
  155. .chitanda_controls {
  156. display: flex;
  157. justify-content: flex-end;
  158. align-items: center;
  159. }
  160. /* 标签导航美化 */
  161. .tab-nav {
  162. display: flex;
  163. border-bottom: 2px solid #ddd;
  164. margin-bottom: 15px;
  165. }
  166. .tab-nav button {
  167. background: none;
  168. border: none;
  169. padding: 10px 20px;
  170. cursor: pointer;
  171. font-size: 14px;
  172. font-weight: bold;
  173. border-bottom: 3px solid transparent;
  174. transition: all 0.3s;
  175. }
  176. .tab-nav button.active {
  177. border-bottom: 3px solid #1E90FF;
  178. color: #1E90FF;
  179. }
  180. .tab-nav button:hover {
  181. color: #1E90FF;
  182. }
  183. /* 选项卡面板优化 */
  184. .tab-panel {
  185. display: none;
  186. }
  187. .tab-panel.active {
  188. display: block;
  189. animation: fadeIn 0.3s ease-in-out;
  190. }
  191. @keyframes fadeIn {
  192. from { opacity: 0; transform: translateY(5px); }
  193. to { opacity: 1; transform: translateY(0); }
  194. }
  195. /* 行内表单优化 */
  196. .flex-row {
  197. display: flex;
  198. gap: 12px;
  199. align-items: center;
  200. }
  201. /* 输入框美化 */
  202. .input-number {
  203. width: 100px;
  204. padding: 8px;
  205. border-radius: 8px;
  206. border: 1px solid #ddd;
  207. background: #f9f9f9;
  208. transition: all 0.2s;
  209. }
  210. .input-number:focus {
  211. border-color: #1E90FF;
  212. box-shadow: 0 0 5px rgba(30, 144, 255, 0.3);
  213. outline: none;
  214. }
  215. </style>
  216. `);
  217. }
  218. /* Wiki 按钮和关联按钮模块 */
  219. function initNavButtons() {
  220. // 排除特定页面
  221. if (/(edit_detail|edit|add_related|upload_img)/.test(location.pathname)) return;
  222. // 获取导航栏
  223. const nav = document.querySelector(".subjectNav .navTabs, .navTabs");
  224. if (!nav) return;
  225. // 匹配页面类型和ID
  226. const pathMatch = location.pathname.match(/\/(subject|person|character)\/(\d+)/);
  227. if (!pathMatch) return;
  228. const pageType = pathMatch[1]; // subject, person, or character
  229. const pageId = pathMatch[2]; // ID number
  230. // 添加Wiki按钮
  231. if (!nav.querySelector(".wiki-button")) {
  232. const wikiUrl = pageType === "subject"
  233. ? `${location.origin}/${pageType}/${pageId}/edit_detail`
  234. : `${location.origin}/${pageType}/${pageId}/edit`;
  235. const wikiLi = document.createElement("li");
  236. wikiLi.className = "wiki-button";
  237. wikiLi.innerHTML = `<a href="${wikiUrl}" target="_blank">Wiki</a>`;
  238. nav.appendChild(wikiLi);
  239. }
  240. // 添加关联按钮
  241. if (!nav.querySelector(".relate-button")) {
  242. const relateUrl = pageType === "subject"
  243. ? `${location.origin}/${pageType}/${pageId}/add_related/subject/anime`
  244. : `${location.origin}/${pageType}/${pageId}/add_related/anime`;
  245. const relateLi = document.createElement("li");
  246. relateLi.className = "relate-button";
  247. relateLi.innerHTML = `<a href="${relateUrl}" target="_blank">关联</a>`;
  248. nav.appendChild(relateLi);
  249. }
  250. }
  251. // 监听 URL 变化
  252. function observeURLChanges() {
  253. let lastURL = location.href;
  254. new MutationObserver(() => {
  255. if (location.href !== lastURL) {
  256. lastURL = location.href;
  257. initNavButtons();
  258. }
  259. }).observe(document, { subtree: true, childList: true });
  260. }
  261. /* 封面上传模块 - 仅在页面无封面时显示 */
  262. async function initCoverUpload() {
  263. // 检查当前页面类型
  264. const url = window.location.href;
  265. const subjectMatch = url.match(/bangumi\.tv\/subject\/(\d+)/);
  266. const personMatch = url.match(/bangumi\.tv\/person\/(\d+)/);
  267. const characterMatch = url.match(/bangumi\.tv\/character\/(\d+)/);
  268. if (!subjectMatch && !personMatch && !characterMatch) return;
  269. // 检查是否已添加上传模块
  270. if (document.querySelector("#coverUploadForm")) return;
  271. // 根据页面类型确定ID和类型
  272. let id, type;
  273. if (subjectMatch) {
  274. id = subjectMatch[1];
  275. type = "subject";
  276. } else if (personMatch) {
  277. id = personMatch[1];
  278. type = "person";
  279. } else {
  280. id = characterMatch[1];
  281. type = "character";
  282. }
  283. // **新逻辑:检查封面是否已存在**
  284. let coverExists = false;
  285. if (type === "subject") {
  286. coverExists = !!document.querySelector("#bangumiInfo img");
  287. } else {
  288. coverExists = !!document.querySelector(".infobox_container img") ||
  289. !!document.querySelector(".subject_sidebar img");
  290. }
  291. if (coverExists) {
  292. console.log("封面已存在,跳过上传模块");
  293. return;
  294. }
  295. // 为不同页面类型找到适当的插入位置
  296. let targetElement;
  297. if (type === "subject") {
  298. targetElement = document.querySelector("#bangumiInfo");
  299. if (!targetElement) return;
  300. const links = document.querySelectorAll(".tip_i p a.l");
  301. if (links.length < 2) return;
  302. try {
  303. const res = await fetch(links[1].href);
  304. const doc = new DOMParser().parseFromString(await res.text(), "text/html");
  305. const form = doc.querySelector("#columnInSubjectA .text form");
  306. if (form) {
  307. const container = document.createElement("div");
  308. container.className = "enhancer-panel";
  309. const clone = form.parentElement.cloneNode(true);
  310. const uploadForm = clone.querySelector("form");
  311. uploadForm.id = "coverUploadForm";
  312. const fileInput = uploadForm.querySelector("input[type=file]");
  313. fileInput.style.width = "100%";
  314. const submitBtn = uploadForm.querySelector("input[type=submit]");
  315. submitBtn.className = "btnCustom";
  316. submitBtn.style.width = "120px";
  317. submitBtn.style.margin = "10px auto 0";
  318. container.appendChild(uploadForm);
  319. targetElement.parentNode.insertBefore(container, targetElement);
  320. }
  321. } catch (e) {
  322. console.error("封面加载失败:", e);
  323. }
  324. } else {
  325. targetElement = document.querySelector(".subject_sidebar") ||
  326. document.querySelector(".infobox_container") ||
  327. document.querySelector(".infobox");
  328. if (!targetElement) return;
  329. try {
  330. // 直接获取上传页面
  331. const uploadUrl = `https://bangumi.tv/${type}/${id}/upload_img`;
  332. const res = await fetch(uploadUrl);
  333. const doc = new DOMParser().parseFromString(await res.text(), "text/html");
  334. const form = doc.querySelector("#columnInSubjectA .text form") ||
  335. doc.querySelector(".text form") ||
  336. doc.querySelector("form[enctype='multipart/form-data']");
  337. if (form) {
  338. const container = document.createElement("div");
  339. container.className = "enhancer-panel";
  340. const clone = form.parentElement.cloneNode(true);
  341. const uploadForm = clone.querySelector("form");
  342. uploadForm.id = "coverUploadForm";
  343. const fileInput = uploadForm.querySelector("input[type=file]");
  344. fileInput.style.width = "100%";
  345. const submitBtn = uploadForm.querySelector("input[type=submit]");
  346. submitBtn.className = "btnCustom";
  347. submitBtn.style.width = "120px";
  348. submitBtn.style.margin = "10px auto 0";
  349. container.appendChild(uploadForm);
  350. targetElement.parentNode.insertBefore(container, targetElement);
  351. }
  352. } catch (e) {
  353. console.error("上传模块加载失败:", e);
  354. }
  355. }
  356. }
  357. // 批量分集编辑器功能模块
  358. const BatchEpisodeEditor = {
  359. CHUNK_SIZE: 20,
  360. BASE_URL: '',
  361. CSRF_TOKEN: '',
  362. // 初始化方法
  363. init() {
  364. if (!this.isEpisodePage()) return;
  365. this.BASE_URL = location.pathname.replace(/\/edit_batch$/, '');
  366. this.CSRF_TOKEN = $('[name=formhash]')?.value || '';
  367. if (!this.CSRF_TOKEN) return;
  368. this.bindHashChange();
  369. this.upgradeCheckboxes();
  370. // 添加功能标识
  371. const header = document.querySelector('h2.subtitle');
  372. if (header) {
  373. const notice = document.createElement('div');
  374. notice.className = 'bgm-enhancer-status';
  375. notice.textContent = '已启用分批编辑功能,支持超过20集的批量编辑';
  376. header.parentNode.insertBefore(notice, header.nextSibling);
  377. }
  378. },
  379. // 检查是否为分集页面
  380. isEpisodePage() {
  381. return /^\/subject\/\d+\/ep(\/edit_batch)?$/.test(location.pathname);
  382. },
  383. // 监听hash变化处理批量编辑
  384. bindHashChange() {
  385. const processHash = () => {
  386. const ids = this.getSelectedIdsFromHash();
  387. if (ids.length > 0) this.handleBatchEdit(ids);
  388. };
  389. window.addEventListener('hashchange', processHash);
  390. if (location.hash.includes('episodes=')) processHash();
  391. },
  392. // 增强复选框功能
  393. upgradeCheckboxes() {
  394. // 动态更新表单action
  395. const updateFormAction = () => {
  396. const ids = $$('[name="ep_mod[]"]:checked').map(el => el.value);
  397. $('form[name="edit_ep_batch"]').action =
  398. `${this.BASE_URL}/edit_batch#episodes=${ids.join(',')}`;
  399. };
  400. $$('[name="ep_mod[]"]').forEach(el =>
  401. el.addEventListener('change', updateFormAction)
  402. );
  403. // 全选功能
  404. $('[name=chkall]')?.addEventListener('click', () => {
  405. $$('[name="ep_mod[]"]').forEach(el => el.checked = true);
  406. updateFormAction();
  407. });
  408. },
  409. // 从hash获取选中ID
  410. getSelectedIdsFromHash() {
  411. const match = location.hash.match(/episodes=([\d,]+)/);
  412. return match ? match[1].split(',').filter(Boolean) : [];
  413. },
  414. // 批量编辑主逻辑
  415. async handleBatchEdit(episodeIds) {
  416. try {
  417. // 分块加载数据
  418. const chunks = this.createChunks(episodeIds, this.CHUNK_SIZE);
  419. const dataChunks = await this.loadChunkedData(chunks);
  420. // 填充表单数据
  421. $('#summary').value = dataChunks.flat().join('\n');
  422. $('[name=ep_ids]').value = episodeIds.join(',');
  423. // 增强表单提交
  424. this.upgradeFormSubmit(chunks, episodeIds);
  425. window.chiiLib?.ukagaka?.presentSpeech('数据加载完成');
  426. } catch (err) {
  427. console.error('批量处理失败:', err);
  428. alert('数据加载失败,请刷新重试');
  429. }
  430. },
  431. // 分块加载数据
  432. async loadChunkedData(chunks) {
  433. window.chiiLib?.ukagaka?.presentSpeech('正在加载分集数据...');
  434. return Promise.all(chunks.map(chunk =>
  435. this.fetchChunkData(chunk).then(data => data.split('\n'))
  436. ));
  437. },
  438. // 获取单块数据
  439. async fetchChunkData(episodeIds) {
  440. const params = new URLSearchParams();
  441. params.append('chkall', 'on');
  442. params.append('submit', '批量修改');
  443. params.append('formhash', this.CSRF_TOKEN);
  444. episodeIds.forEach(id => params.append('ep_mod[]', id));
  445. const res = await fetch(`${this.BASE_URL}/edit_batch`, {
  446. method: 'POST',
  447. headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  448. body: params
  449. });
  450. const html = await res.text();
  451. const match = html.match(/<textarea [^>]*name="ep_list"[^>]*>([\s\S]*?)<\/textarea>/i);
  452. return match?.[1]?.trim() || '';
  453. },
  454. // 增强表单提交处理
  455. upgradeFormSubmit(chunks, originalIds) {
  456. const form = $('form[name="edit_ep_batch"]');
  457. if (!form) return;
  458. form.onsubmit = async (e) => {
  459. e.preventDefault();
  460. // 验证数据完整性
  461. const inputData = $('#summary').value.trim().split('\n');
  462. if (inputData.length !== originalIds.length) {
  463. alert(`数据不匹配 (预期 ${originalIds.length} 行,实际 ${inputData.length} 行)`);
  464. return;
  465. }
  466. try {
  467. window.chiiLib?.ukagaka?.presentSpeech('正在提交数据...');
  468. await this.saveChunkedData(chunks, inputData);
  469. window.chiiLib?.ukagaka?.presentSpeech('保存成功');
  470. location.href = this.BASE_URL;
  471. } catch (err) {
  472. console.error('保存失败:', err);
  473. alert('保存过程中发生错误');
  474. }
  475. };
  476. },
  477. // 分块保存数据
  478. async saveChunkedData(chunks, fullData) {
  479. const dataChunks = this.createChunks(fullData, this.CHUNK_SIZE);
  480. return Promise.all(chunks.map((idChunk, index) =>
  481. this.saveChunkData(idChunk, dataChunks[index])
  482. ));
  483. },
  484. // 保存单块数据
  485. async saveChunkData(episodeIds, chunkData) {
  486. const params = new URLSearchParams();
  487. params.append('formhash', this.CSRF_TOKEN);
  488. params.append('rev_version', '0');
  489. params.append('editSummary', $('#editSummary')?.value || '');
  490. params.append('ep_ids', episodeIds.join(','));
  491. params.append('ep_list', chunkData.join('\n'));
  492. params.append('submit_eps', '改好了');
  493. await fetch(`${this.BASE_URL}/edit_batch`, {
  494. method: 'POST',
  495. headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  496. body: params
  497. });
  498. },
  499. // 通用分块方法
  500. createChunks(array, size) {
  501. return Array.from(
  502. { length: Math.ceil(array.length / size) },
  503. (_, i) => array.slice(i * size, (i + 1) * size)
  504. );
  505. }
  506. };
  507. // ID收集器 - 用于条目/角色/人物主页
  508. function initIDCollector() {
  509. injectStyles();
  510. // 获取当前页面ID
  511. const getPageID = () => {
  512. const match = location.pathname.match(/\/(subject|character|person)\/(\d+)/);
  513. return match ? parseInt(match[2]) : null;
  514. };
  515. const currentID = getPageID();
  516. // 创建界面容器
  517. const container = document.createElement("div");
  518. container.innerHTML = `
  519. <div class="enhancer-panel">
  520. <h3>批量关联助手</h3>
  521. <p>当前位置:ID收集 - 点击下方按钮前往关联页面</p>
  522. <div style="text-align: center">
  523. <a href="${window.location.pathname}/add_related/subject" class="btnCustom">
  524. 前往批量关联页面
  525. </a>
  526. </div>
  527. </div>
  528. `;
  529. // 插入到页面
  530. const targetNode = $("#sbjSearchMod") || $(".main_content");
  531. if (targetNode.length) targetNode.after(container);
  532. }
  533. // 批量关联功能 - 用于关联页面
  534. function initBatchRelation() {
  535. injectStyles();
  536. // 参数配置
  537. const DELAY_AFTER_CLICK = 250;
  538. const DELAY_BETWEEN_ITEMS = 500;
  539. const MAX_RETRY_ATTEMPTS = 10;
  540. const RETRY_INTERVAL = 100;
  541. // 全局变量
  542. let globalItemType = '1';
  543. let currentProcessingIndex = -1;
  544. // 根据当前 URL 判断页面类型
  545. function getCurrentPageType() {
  546. const url = window.location.href;
  547. if (url.indexOf('/add_related/character') !== -1) {
  548. return 'character';
  549. } else if (url.indexOf('/add_related/subject') !== -1) {
  550. return 'subject';
  551. } else {
  552. return 'person';
  553. }
  554. }
  555. // 生成关联类型选择下拉框
  556. function generateTypeSelector() {
  557. const pageType = getCurrentPageType();
  558. if (pageType === 'character') {
  559. let options = '';
  560. const relationTypes = {
  561. '1': '主角',
  562. '2': '配角',
  563. '3': '客串'
  564. };
  565. for (let [value, text] of Object.entries(relationTypes)) {
  566. options += `<option value="${value}">${text}</option>`;
  567. }
  568. return `<span class="select-label">类型: </span><select>${options}</select>`;
  569. } else {
  570. // 如果有 genPrsnStaffList 函数则调用,否则返回空字符串
  571. return `<span class="select-label"></span>${(typeof genPrsnStaffList === "function") ? genPrsnStaffList(-1) : ''}`;
  572. }
  573. }
  574. // 针对传入的元素内的下拉框进行设置,并通过递归确保修改成功
  575. function setRelationTypeWithElement($li, item_type) {
  576. return new Promise((resolve) => {
  577. let attempts = 0;
  578. function trySet() {
  579. // 确保我们获取的是当前元素内部的select,而不是全局的
  580. let $select = $li.find('select').first();
  581. if ($select.length > 0) {
  582. // 先确保下拉框可交互
  583. if ($select.prop('disabled')) {
  584. setTimeout(trySet, RETRY_INTERVAL);
  585. return;
  586. }
  587. $select.val(item_type);
  588. // 触发 change 事件
  589. const event = new Event('change', { bubbles: true });
  590. $select[0].dispatchEvent(event);
  591. setTimeout(() => {
  592. if ($select.val() == item_type) {
  593. resolve(true);
  594. } else if (attempts < MAX_RETRY_ATTEMPTS) {
  595. attempts++;
  596. setTimeout(trySet, RETRY_INTERVAL);
  597. } else {
  598. resolve(false);
  599. }
  600. }, 200);
  601. } else if (attempts < MAX_RETRY_ATTEMPTS) {
  602. attempts++;
  603. setTimeout(trySet, RETRY_INTERVAL);
  604. } else {
  605. resolve(false);
  606. }
  607. }
  608. trySet();
  609. });
  610. }
  611. // 点击项目后利用 MutationObserver 监听新增条目,然后对该条目的下拉框设置类型
  612. function processItem(element, item_type) {
  613. return new Promise((resolve) => {
  614. // 关联列表容器
  615. const container = document.querySelector('#crtRelat###bjects');
  616. if (!container) {
  617. return resolve(false);
  618. }
  619. // 保存处理前的条目列表
  620. const initialItems = Array.from(container.children);
  621. // 绑定 MutationObserver 监听子节点变化
  622. const observer = new MutationObserver((mutations) => {
  623. // 获取当前所有条目
  624. const currentItems = Array.from(container.children);
  625. // 找出新增的条目(在当前列表中但不在初始列表中的元素)
  626. const newItems = currentItems.filter(item => !initialItems.includes(item));
  627. if (newItems.length > 0) {
  628. observer.disconnect();
  629. const newItem = newItems[0]; // 获取第一个新增条目
  630. // 确保等待DOM完全渲染
  631. setTimeout(async () => {
  632. // 使用新的条目元素直接查找其内部的select
  633. const $select = $(newItem).find('select');
  634. if ($select.length > 0) {
  635. const success = await setRelationTypeWithElement($(newItem), item_type);
  636. resolve(success);
  637. } else {
  638. resolve(false);
  639. }
  640. }, DELAY_AFTER_CLICK);
  641. }
  642. });
  643. observer.observe(container, { childList: true, subtree: true });
  644. // 触发点击
  645. $(element).click();
  646. // 超时防护
  647. setTimeout(() => {
  648. observer.disconnect();
  649. resolve(false);
  650. }, MAX_RETRY_ATTEMPTS * RETRY_INTERVAL);
  651. });
  652. }
  653. // 处若搜索结果不唯一且没有完全匹配项则自动选择第一个
  654. function normalizeText(text) {
  655. return text.normalize("NFC").replace(/\s+/g, '').replace(/[\u200B-\u200D\uFEFF]/g, '').trim();
  656. }
  657. function extractTextFromElement(el) {
  658. if (!el) return '';
  659. let text = el.innerText || el.textContent || $(el).text();
  660. // 尝试从 `iframe` 和 `shadowRoot` 获取文本
  661. if (!text.trim()) {
  662. if (el.shadowRoot) {
  663. text = [...el.shadowRoot.querySelectorAll('*')].map(e => e.textContent).join('');
  664. }
  665. let iframe = el.querySelector('iframe');
  666. if (iframe && iframe.contentDocument) {
  667. text = iframe.contentDocument.body.textContent;
  668. }
  669. }
  670. return normalizeText(text);
  671. }
  672. async function processSingleItem(elements, item_type, search_name) {
  673. return new Promise(async (resolve) => {
  674. if (elements.length === 0) {
  675. $('.chitanda_item_not_found').append(search_name + ' ');
  676. resolve(false);
  677. return;
  678. }
  679. let elementsArray = elements.toArray();
  680. let normalizedSearchName = normalizeText(search_name);
  681. console.log("搜索名(规范化):", normalizedSearchName);
  682. // 等待元素加载,避免空文本
  683. await new Promise(res => setTimeout(res, 500));
  684. let selectedElement = elementsArray.find(el => {
  685. let normalizedElementText = extractTextFromElement(el);
  686. console.log("元素文本(规范化):", normalizedElementText); // 调试用
  687. return normalizedElementText === normalizedSearchName;
  688. });
  689. if (!selectedElement) {
  690. if (elements.length > 1) {
  691. $('.chitanda_item_dupe').append(`${search_name} `);
  692. }
  693. selectedElement = elements[0]; // 没有完全匹配,取第一个
  694. }
  695. resolve(await processItem(selectedElement, item_type));
  696. });
  697. }
  698. // 处理下一个项目
  699. async function proceedToNextItem(idx, item_list, item_type, item_num) {
  700. if (idx < item_num - 1) {
  701. setTimeout(async () => {
  702. await ctd_findItemFunc(item_list, item_type, idx + 1);
  703. }, DELAY_BETWEEN_ITEMS);
  704. } else {
  705. setTimeout(() => {
  706. $('#subjectList').empty();
  707. $('#subjectList').show();
  708. alert('全部添加完成');
  709. }, DELAY_BETWEEN_ITEMS);
  710. }
  711. }
  712. // 核心查找及处理函数:依次检索每个条目并处理
  713. var ctd_findItemFunc = async function(item_list, item_type, idx) {
  714. currentProcessingIndex = idx;
  715. item_type = globalItemType;
  716. let search_name = item_list[idx].trim();
  717. if (!search_name) {
  718. proceedToNextItem(idx, item_list, item_type, item_list.length);
  719. return;
  720. }
  721. var item_num = item_list.length;
  722. $('#subjectList').html('<tr><td>正在检索中...</td></tr>');
  723. var search_mod = $('#sbjSearchMod').attr('value');
  724. try {
  725. const response = await new Promise((resolve, reject) => {
  726. $.ajax({
  727. type: "GET",
  728. url: '/json/search-' + search_mod + '/' + encodeURIComponent(search_name),
  729. dataType: 'json',
  730. success: resolve,
  731. error: reject
  732. });
  733. });
  734. var html = '';
  735. if ($(response).length > 0) {
  736. subjectList = response;
  737. for (var i in response) {
  738. if ($.inArray(search_mod, enableStaffSbjType) != -1) {
  739. html += genSubjectList(response[i], i, 'submitForm');
  740. } else {
  741. html += genSubjectList(response[i], i, 'searchR###lt');
  742. }
  743. }
  744. $('#subjectList').html(html);
  745. $('.chitanda_current_idx').text(idx + 1);
  746. $('.chitanda_all_num').text(item_num);
  747. await new Promise(resolve => setTimeout(resolve, 400)); // 减少等待时间
  748. var elements = $('#subjectList>li>a.avatar.h');
  749. if (window.location.pathname.includes('/person/') && window.location.pathname.includes('/add_related/character/anime')) {
  750. if (elements.length === 0) {
  751. $('.chitanda_item_not_found').append(search_name + ' ');
  752. } else {
  753. $(elements[0]).click();
  754. if (elements.length > 1) {
  755. $('.chitanda_item_dupe').append(`${search_name} `);
  756. }
  757. }
  758. $('.chitanda_current_idx').text(idx + 1);
  759. if (idx < item_num - 1) {
  760. setTimeout(async () => {
  761. await ctd_findItemFunc(item_list, item_type, idx + 1);
  762. }, DELAY_BETWEEN_ITEMS);
  763. } else {
  764. setTimeout(() => {
  765. $('#subjectList').empty();
  766. $('#subjectList').show();
  767. alert('全部添加完成');
  768. }, DELAY_BETWEEN_ITEMS);
  769. }
  770. } else {
  771. await processSingleItem(elements, item_type, search_name, idx, item_list, item_num);
  772. await proceedToNextItem(idx, item_list, item_type, item_num);
  773. }
  774. } else {
  775. $("#robot").fadeIn(300);
  776. $("#robot_balloon").html(`没有找到 ${search_name} 的相关结果`);
  777. $("#robot").animate({ opacity: 1 }, 500).fadeOut(500); // 减少动画时间
  778. $('.chitanda_item_not_found').append(search_name + ' ');
  779. $('#subjectList').html(html);
  780. $('.chitanda_current_idx').text(idx + 1);
  781. $('.chitanda_all_num').text(item_num);
  782. await proceedToNextItem(idx, item_list, item_type, item_num);
  783. }
  784. } catch (error) {
  785. console.error('查询出错:', error);
  786. $("#robot").fadeIn(300);
  787. $("#robot_balloon").html('通信错误,您是不是重复查询太快了?');
  788. $("#robot").animate({ opacity: 1 }, 500).fadeOut(1000); // 减少动画时间
  789. $('#subjectList').html('');
  790. setTimeout(async () => {
  791. if (idx < item_list.length - 1) {
  792. await ctd_findItemFunc(item_list, item_type, idx + 1);
  793. } else {
  794. $('#subjectList').empty();
  795. $('#subjectList').show();
  796. alert('全部添加完成,但部分查询出错');
  797. }
  798. }, 1500); // 减少等待时间
  799. }
  800. };
  801. // 从ID范围中提取ID列表
  802. function getIDsFromRange(start, end) {
  803. const startID = parseInt(start, 10);
  804. const endID = parseInt(end, 10);
  805. if (isNaN(startID) || isNaN(endID) || startID > endID) {
  806. alert("ID范围无效");
  807. return [];
  808. }
  809. return Array.from({ length: endID - startID + 1 }, (_, i) => "bgm_id=" + (startID + i));
  810. }
  811. // 从自由文本中提取ID列表
  812. function getIDsFromText(input) {
  813. if (!input.trim()) {
  814. alert("请输入ID或内容");
  815. return [];
  816. }
  817. // 先检查是否以 bgm_id= 开头
  818. if (input.startsWith("bgm_id=")) {
  819. return input.substring(7)
  820. .split(/[,\n\r,、\/|;。.()【】<>!?]+| +/)
  821. .map(id => "bgm_id=" + id.trim())
  822. .filter(id => id);
  823. }
  824. // 识别 URL 形式的 ID
  825. const urlPattern = /(bgm\.tv|bangumi\.tv|chii\.in)\/(subject|character|person)\/(\d+)/g;
  826. const urlMatches = [...input.matchAll(urlPattern)].map(m => m[3]);
  827. if (urlMatches.length > 0) {
  828. return urlMatches.map(id => "bgm_id=" + id);
  829. }
  830. // 识别纯数字 ID
  831. const numberPattern = /\b\d+\b/g;
  832. const numberMatches = [...input.matchAll(numberPattern)].map(m => m[0]);
  833. if (numberMatches.length > 0) {
  834. return numberMatches.map(id => "bgm_id=" + id);
  835. }
  836. // 默认按符号分隔(空格优先级最低)
  837. return input.split(/[,\n\r,、\/|;。.()【】<>!?]+/) // 先按主要分隔符拆分
  838. .map(part => part.trim()) // 去除前后空格
  839. .filter(part => part.length > 0) // 过滤掉空字符串
  840. .flatMap(part => {
  841. // 保持以下格式不被拆分:
  842. // 1. "第3季"、"第2集"
  843. // 2. "Ⅱ", "Ⅲ", "Ⅳ"(罗马数字)
  844. // 3. "鬼灭之刃 3" 这种名字+数字
  845. return /^(第?\d+|[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+)$/.test(part) || /\D+\d+$/.test(part) ? [part] : part.split(/ +/);
  846. });
  847. }
  848. // 批量查找入口函数
  849. var chitanda_MultiFindItemFunc = async function() {
  850. let item_type = '1';
  851. let typeSelector = $('.chitanda_item_type select');
  852. if (typeSelector.length > 0) {
  853. item_type = typeSelector.val();
  854. if (item_type == '-999') {
  855. alert('请先选择关联类型');
  856. return false;
  857. }
  858. globalItemType = item_type;
  859. }
  860. let ctd_item_list = [];
  861. const activeTab = $('.tab-panel.active').attr('id');
  862. if (activeTab === 'tab-text') {
  863. // 处理文本输入模式
  864. const inputVal = $('#custom_ids').val().trim();
  865. ctd_item_list = getIDsFromText(inputVal);
  866. } else if (activeTab === 'tab-range') {
  867. // 处理ID范围模式
  868. const startID = $('#id_start').val().trim();
  869. const endID = $('#id_end').val().trim();
  870. ctd_item_list = getIDsFromRange(startID, endID);
  871. }
  872. if (ctd_item_list.length === 0) {
  873. return false;
  874. }
  875. $('#subjectList').hide();
  876. $('.chitanda_item_not_found').empty();
  877. $('.chitanda_item_dupe').empty();
  878. $('.chitanda_current_idx').text('0');
  879. $('.chitanda_all_num').text(ctd_item_list.length);
  880. currentProcessingIndex = -1;
  881. await ctd_findItemFunc(ctd_item_list, item_type, 0);
  882. };
  883. // 切换标签页
  884. function switchTab(tabId) {
  885. $('.tab-nav button').removeClass('active');
  886. $(`.tab-nav button[data-tab="${tabId}"]`).addClass('active');
  887. $('.tab-panel').removeClass('active');
  888. $(`#${tabId}`).addClass('active');
  889. }
  890. // 根据页面类型设定 UI 标题
  891. let uiTitle = '人物';
  892. const pageType = getCurrentPageType();
  893. if (pageType === 'character') {
  894. uiTitle = '角色';
  895. } else if (pageType === 'subject') {
  896. uiTitle = '条目';
  897. }
  898. // 创建改进的UI界面
  899. $('.subjectListWrapper').after(`
  900. <div class="chitanda_wrapper">
  901. <h3>批量关联助手</h3>
  902. <div class="tab-nav">
  903. <button data-tab="tab-text" class="active">自由文本输入</button>
  904. <button data-tab="tab-range">ID范围输入</button>
  905. </div>
  906. <div id="tab-text" class="tab-panel active">
  907. <textarea id="custom_ids" class="enhancer-textarea"
  908. placeholder="输入ID或网址(支持多种格式:纯数字、bgm_id=xx、网址、文本,可用各类符号(空格优先级最低)分隔)"></textarea>
  909. </div>
  910. <div id="tab-range" class="tab-panel">
  911. <div class="flex-row" style="justify-content: center">
  912. <input id="id_start" type="number" placeholder="起始ID" class="input-number">
  913. <span style="line-height: 30px">~</span>
  914. <input id="id_end" type="number" placeholder="结束ID" class="input-number">
  915. </div>
  916. </div>
  917. <div class="chitanda_controls" style="margin-top: 10px">
  918. <span class="chitanda_item_type"></span>
  919. <button id="btn_ctd_multi_search" class="btnCustom">批量关联</button>
  920. </div>
  921. <div class="chitanda_progress">
  922. 添加进度:<span class="chitanda_current_idx">0</span>/<span class="chitanda_all_num">0</span>
  923. </div>
  924. <div class="chitanda_header">未找到的${uiTitle}:</div>
  925. <div class="chitanda_item_not_found"></div>
  926. <div class="chitanda_header">存在多个结果的${uiTitle}(已自动选择第一个):</div>
  927. <div class="chitanda_item_dupe"></div>
  928. </div>
  929. `);
  930. // 添加关联类型选择器
  931. $('.chitanda_item_type').append(generateTypeSelector());
  932. $('.chitanda_item_type select').prepend('<option value="-999">请选择关联类型</option>').val('-999');
  933. // 绑定事件
  934. $('#btn_ctd_multi_search').on('click', chitanda_MultiFindItemFunc);
  935. $('.chitanda_item_type select').on('change', function() {
  936. globalItemType = $(this).val();
  937. });
  938. $('.tab-nav button').on('click', function() {
  939. switchTab($(this).data('tab'));
  940. });
  941. }
  942. /* 启动所有功能 */
  943. function startEnhancer() {
  944. initNavButtons();
  945. observeURLChanges();
  946. initCoverUpload();
  947. BatchEpisodeEditor.init();
  948. console.log("Bangumi Ultimate Enhancer 已启动");
  949. }
  950. // 在DOM加载完成后启动脚本
  951. if (document.readyState === 'loading') {
  952. document.addEventListener('DOMContentLoaded', startEnhancer);
  953. } else {
  954. startEnhancer();
  955. }
  956. })();