🏠 返回首頁 

Greasy Fork is available in English.

MAL (MyAnimeList) Dubs

Labels English dubbed titles on MyAnimeList.net and adds dub only filtering

  1. // ==UserScript==
  2. // @name MAL (MyAnimeList) Dubs
  3. // @namespace https://github.com/MAL-Dubs
  4. // @version 1.2.2
  5. // @description Labels English dubbed titles on MyAnimeList.net and adds dub only filtering
  6. // @author MAL Dubs
  7. // @supportURL https://github.com/MAL-Dubs/MAL-Dubs/issues
  8. // @match https://myanimelist.net/*
  9. // @iconURL https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/images/icon.png
  10. // @license GNU AGPLv3; https://www.gnu.org/licenses/agpl-3.0.html
  11. // @resource CSS https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/css/style.css
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_addStyle
  14. // @grant GM_getResourceText
  15. // @connect githubusercontent.com
  16. // @connect github.com
  17. // @run-at document-end
  18. // @noframes
  19. // ==/UserScript==
  20. const currentURL = document.location.href;
  21. const currentBodyClassList = document.body.classList;
  22. const animeURLregex = /^(https?:\/\/myanimelist\.net)?\/?anime(\/|\.php\?id=)(\d+)\/?.*$/;
  23. const IDURL = 'https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/data/dubInfo.json';
  24. let dubInfo = JSON.parse(localStorage.getItem('dubInfo'));
  25. GM_addStyle(GM_getResourceText('CSS'));
  26. function cacheDubs() {
  27. const lastCached = localStorage.getItem('dubCacheDate');
  28. if (
  29. lastCached === null
  30. || dubInfo === null
  31. || (lastCached !== undefined && parseInt(lastCached, 10) + 600000 < Date.now())
  32. ) {
  33. GM_xmlhttpRequest({
  34. method: 'GET',
  35. url: IDURL,
  36. nocache: true,
  37. revalidate: true,
  38. onload(response) {
  39. dubInfo = JSON.parse(response.responseText);
  40. localStorage.setItem('dubInfo', JSON.stringify(dubInfo));
  41. localStorage.setItem('dubCacheDate', Date.now());
  42. },
  43. });
  44. }
  45. }
  46. function hasBodyClass(selector) {
  47. return document.body.classList.contains(selector);
  48. }
  49. function labelDub(linkNode, image = false, labelNode = false, linkURL = linkNode.href) {
  50. if (animeURLregex.test(linkURL)) {
  51. const linkID = parseInt(animeURLregex.exec(linkURL)[3], 10);
  52. let animeElement = linkNode;
  53. let dubData = 'no';
  54. if (labelNode) {
  55. animeElement = linkNode.querySelector(labelNode) || linkNode.closest(labelNode);
  56. }
  57. Object.keys(dubInfo).every((key) => {
  58. if (dubInfo[key].includes(linkID)) {
  59. dubData = key;
  60. if (image) { animeElement.classList.add('imagelink'); }
  61. return false;
  62. }
  63. return true;
  64. });
  65. animeElement.dataset.dub = dubData;
  66. }
  67. }
  68. function labelThumbnails(container = document.body) {
  69. const dubbedThumbs = 'div.auto-recommendations>div.items>a.item,div.recommendations div.items>a.item,div#widget-seasonal-video li.btn-anime>a.link,div#anime_recommendation li.btn-anime.auto>a.link,.js-seasonal-anime>.image>a:nth-child(1),#anime_favorites>.fav-slide-outer>ul>li>a';
  70. container.querySelectorAll(dubbedThumbs).forEach((e) => labelDub(e, true));
  71. }
  72. function watchForDubs(r###ltSelector, pageType, containerID = 'content') {
  73. const container = document.getElementById(containerID);
  74. const options = { childList: true, subtree: true };
  75. let args = [];
  76. if (pageType === 'search') {
  77. options.attributes = true;
  78. options.attributeFilter = ['href'];
  79. args = [undefined, 'div.info.anime>div.name'];
  80. } else if (pageType === 'statistics') { args = [true]; }
  81. new MutationObserver(() => {
  82. container.querySelectorAll(r###ltSelector).forEach((e) => labelDub(e, ...args));
  83. }).observe(container, options);
  84. }
  85. function filterContainers() {
  86. const dubDataLinks = document.body.querySelectorAll(':not([data-dub-container]) [data-dub]');
  87. const selectors = '.seasonal-anime.js-seasonal-anime.js-anime-type-all,.js-block-list.list>table>tbody>tr,tr.ranking-list';
  88. dubDataLinks.forEach((e) => {
  89. const container = e.closest(selectors);
  90. if (container !== undefined && container !== null) {
  91. container.dataset.dubContainer = e.dataset.dub;
  92. }
  93. });
  94. }
  95. function addDubFilter(targetNode, position = 'afterend') {
  96. filterContainers();
  97. const labelClass = 'fs11 fl-r fw-n fn-grey2 mr12';
  98. // if (mobile === true) {
  99. // labelClass = 'btn-filter fa-stack fs14 mr12';
  100. // }
  101. if (targetNode !== null) {
  102. const filterButton = document.createElement('input');
  103. filterButton.type = 'button';
  104. filterButton.id = 'dub-filter';
  105. targetNode.insertAdjacentElement(position, filterButton);
  106. filterButton.insertAdjacentHTML('afterend', `
  107. <label for="dub-filter" class="btn-show-dubs ${labelClass}">
  108. <i class="fa-regular fa-square fa-stack-2x"></i>
  109. <i class="fa-solid fa-xmark fa-stack-1x"></i>
  110. <i class="fa-solid fa-check fa-stack-1x"></i>
  111. Dubbed
  112. </label>`.trim());
  113. const filterOptions = ['filter-off', 'only-dubs', 'no-dubs'];
  114. let filter = localStorage.getItem('dubFilter') || 'filter-off';
  115. currentBodyClassList.add(filter);
  116. let filterIndex = filterOptions.indexOf(filter);
  117. filterButton.addEventListener('click', () => {
  118. currentBodyClassList.replace(
  119. filter,
  120. (filter = filterOptions[(filterIndex += 1) % filterOptions.length]),
  121. );
  122. localStorage.setItem('dubFilter', filter);
  123. if (hasBodyClass('season')) {
  124. const titlesArray = [].slice.call(document.querySelectorAll('.seasonal-anime'));
  125. const showingArray = titlesArray.filter((el) => getComputedStyle(el).display !== 'none');
  126. const countDisplay = document.querySelector('.js-visible-anime-count');
  127. countDisplay.textContent = `${showingArray.length}/${titlesArray.length}`;
  128. }
  129. }, false);
  130. }
  131. }
  132. function hideOnClickOutside(element) {
  133. const outsideClickListener = (event) => {
  134. const isVisible = !!(element && element.offsetWidth && element.offsetHeight);
  135. if (!element.contains(event.target) && isVisible) {
  136. element.classList.remove('on');
  137. document.removeEventListener('click', outsideClickListener);
  138. }
  139. };
  140. document.addEventListener('click', outsideClickListener);
  141. }
  142. function placeHeaderMenu() {
  143. const menuContainer = document.createElement('div');
  144. const borderDiv = document.createElement('div');
  145. menuContainer.id = 'dubmenu';
  146. menuContainer.classList.add('header-menu-unit', 'header-dub');
  147. menuContainer.insertAdjacentHTML('afterbegin', '<a id="menu-toggle" title="MAL-Dubs" tabindex="0" class="header-dub-button text1"><span id="menu-toggle" class="dub-icon icon"></span></a><div id="dub-dropdown"><ul><li><a id="theme-toggle" href="#"><i class="dub-icon mr6"></i>Switch Style</a></li><li><a href="https://myanimelist.net/forum/?topicid=1692966"><i class="fa-solid fa-calendar-clock mr6"></i>Upcoming Dubs</a></li><li><a href="https://myanimelist.net/forum/?action=message&amp;topic_id=1952777&amp;action=message"><i class="fa-solid fa-comment-dots mr6"></i>Send Feedback</a></li><li><a href="https://github.com/MAL-Dubs/MAL-Dubs/issues/new/choose" target="_blank" rel="noreferrer"><i class="fa-brands fa-github mr6"></i>Report an Issue</a></li><li><a href="https://discord.gg/wMfD2RM7Vt" target="_blank" rel="noreferrer"><i class="fa-brands fa-discord mr6"></i>Discord</a></li><li><a href="https://ko-fi.com/maldubs" target="_blank" rel="noreferrer"><i class="fa-solid fa-circle-dollar-to-slot mr6"></i>Donate</a></li></ul></div>');
  148. borderDiv.classList.add('border');
  149. function toggleMenu() {
  150. const dropdown = document.getElementById('dubmenu');
  151. dropdown.classList.toggle('on');
  152. if (dropdown.classList.contains('on')) { hideOnClickOutside(dropdown); }
  153. }
  154. function switchStyle() {
  155. const isClassic = localStorage.getItem('classicTheme') === 'true';
  156. currentBodyClassList.toggle('classic', !isClassic);
  157. localStorage.setItem('classicTheme', !isClassic);
  158. }
  159. const headerMenu = document.querySelector('#header-menu');
  160. if (headerMenu) {
  161. const targetElement = headerMenu.querySelector('.header-profile')
  162. || headerMenu.querySelector('.header-menu-login');
  163. if (targetElement) { targetElement.before(menuContainer, borderDiv); }
  164. }
  165. document.getElementById('theme-toggle').addEventListener('click', switchStyle, false);
  166. document.getElementById('menu-toggle').addEventListener('click', toggleMenu, false);
  167. }
  168. cacheDubs();
  169. if (hasBodyClass('page-common')) {
  170. const dubbedLinks = document.querySelectorAll("p.title-text>a,p.data a.title,.content-r###lt .information>a:first-child,.list>.information>a:first-child,table.anime_detail_related_anime a[href^='/anime'],.content-r###lt .title>a:first-child,td.data.title>a:first-child,.list td:nth-child(2)>a.hoverinfo_trigger,#content>table>tbody>tr>td>table>tbody>tr>td.borderClass>a[href*='myanimelist.net/anime/'],#content>div>div>table>tbody>tr>td>a[href*='/anime'],div[id^=raArea]+div>a:first-child,.news-container h2 a[href*='/anime/'],li.ranking-unit>div>h3>a,tr.ranking-list>td:nth-child(2)>div>div>h3>a,div.borderClass>a[href^='anime/'],#content>table>tbody>tr>td:nth-child(1)>a:nth-child(1),[id^='#revAreaItemTrigger'],.news-container a[href^='https://myanimelist.net/anime/'],.animeography>.title>a,.profile div.updates.anime>div.statistics-updates>div.data>a,.history_content_wrapper td:first-child>a,.forum-topic-message a[href^='https://myanimelist.net/anime/'],.forum-topic-message a[href^='/anime/'],.page-blog-detail a[href^='https://myanimelist.net/anime/'],.page-blog-detail a[href^='/anime/'],.pmessage-message-history a[href^='https://myanimelist.net/anime/'],.pmessage-message-history a[href^='/anime/'],a.js-people-title,.profile .anime>div>div.data>div.title>a,a[id^='sinfo'],[id^='#revAreaAnimeHover'],#dialog>tbody>tr>td>a,.company-favorites-ranking-table>tbody>tr>td.popularity>p>a,.company-favorites-ranking-table>tbody>tr>td.score>p>a,div.list.js-categories-seasonal>table>tbody>tr>td:nth-child(2)>div:nth-child(1)>a,.stacks #content>div.content-left>div.list-anime-list>div>div.head>div.title-text>h2>a,.footer-ranking li>a,#content > div:nth-child(3) > a[href^='/anime'],.blog_detail_content_wrapper a,div[id^=comment]>table>tbody>tr>td:nth-child(2)>a,.recommendations_h3>a,.reviews_h3>a,.friend_list_updates a.fw-b,.info>p>a.fw-b,#content>div.borderDark>table>tbody>tr>td>a,.js-history-updates-item .work-title>a:first-of-type,.review-element .titleblock .title,.video-info-title>a:nth-child(2),div.related-entries div.title>a,div.related-entries ul.entries>li>a");
  171. const searchURLregex = /.*\/((anime\.php\??(?!id).*)|anime\/genre\/?.*)/;
  172. const filterableURLregex = /.*\/(((anime\.php\?(?!id).+|topanime\.php.*))|anime\/(genre|producer|season)\/?.*)/;
  173. dubbedLinks.forEach((e) => { labelDub(e); });
  174. watchForDubs('#top-search-bar>#topSearchR###ltList>div>div>a', 'search', 'menu_right');
  175. placeHeaderMenu();
  176. labelThumbnails();
  177. setTimeout(() => labelThumbnails(), 400);
  178. if (filterableURLregex.test(currentURL)) {
  179. const filterTarget = `.js-search-filter-block>div:last-of-type,
  180. div.horiznav-nav-seasonal>span.js-btn-show-sort:last-of-type,
  181. h2.top-rank-header2>span:last-of-type,
  182. .normal_header>div.view-style2:last-of-type,
  183. .normal_header>div.fl-r.di-ib.fs11.fw-n`;
  184. addDubFilter(document.querySelector(filterTarget));
  185. }
  186. if (searchURLregex.test(currentURL)) {
  187. watchForDubs('#advancedSearchR###ltList>div>div>a', 'search');
  188. }
  189. if (animeURLregex.test(currentURL)) {
  190. const animePageID = animeURLregex.exec(currentURL)[3];
  191. const recommendations = document.querySelectorAll('#anime_recommendation>div.anime-slide-outer>.anime-slide>li.btn-anime>a.link:not([href*="suggestion"])');
  192. labelDub(document.querySelector('h1.title-name'), false, false, currentURL);
  193. const recRegex = new RegExp(`recommendations\\/|-*${animePageID}-*`, 'g');
  194. recommendations.forEach((e) => labelDub(e, true, false, e.href.replace(recRegex, '')));
  195. } else if (hasBodyClass('page-forum')) {
  196. watchForDubs('div.message-container>div.content>table.body a[href^="https://myanimelist.net/anime/"]:not([data-dub])');
  197. } else if (currentURL === 'https://myanimelist.net/addtolist.php') {
  198. watchForDubs('.quickAdd-anime-r###lt-unit>table>tbody>tr>td:nth-child(1)>a');
  199. } else if (hasBodyClass('statistics')) {
  200. watchForDubs('#statistics-anime-score-diff-desc .container .item>a, #statistics-anime-score-diff-asc .container .item>a', 'statistics');
  201. } else if (hasBodyClass('notification')) {
  202. watchForDubs('li.related_anime_add>div>div>a');
  203. }
  204. } else if (hasBodyClass('ownlist')) {
  205. if (hasBodyClass('anime')) {
  206. watchForDubs('#list-container>div.list-block>div>table>tbody.list-item>tr.list-table-data>td.data.title>a.link:not([data-dub])', undefined, 'list-container');
  207. } else {
  208. const listDubs = document.body.querySelectorAll('div#list_surround>table>tbody>tr>td>a.animetitle');
  209. listDubs.forEach((e) => labelDub(e));
  210. }
  211. }
  212. if (localStorage.getItem('classicTheme') === 'true') { currentBodyClassList.add('classic'); }