// ==UserScript== // @name AniList:RE // @namespace Kellen's userstyles // @version 2.0.0 // @description For anilist.co, this script enhances the display of anime and manga information by adding scores from multiple sources to the header, relocating and formatting genres and tags, and displaying both romaji and native titles. // @author kln (t.me/kln_lzt) // @homepageURL https://github.com/Kellenok/userscipts/ // @supportURL https://github.com/Kellenok/userscipts/issues // @match https://anilist.co/* // @connect graphql.anilist.co // @connect api.jikan.moe // @connect kitsu.io // @connect shikimori.one // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @license MIT // @downloadURL none // ==/UserScript== // This user script was tested with the following user script managers: // - Violentmonkey (preferred): https://violentmonkey.github.io/ // - TamperMonkey: https://www.tampermonkey.net/ // - GreaseMonkey: https://www.greasespot.net/ (async function() { 'use strict'; /** * Default user configuration options. * * You can override these options if your user script runner supports it. Your * changes will persist across user script updates. * * In Violentmonkey: * 1. Install the user script. * 2. Let the script run at least once by loading an applicable url. * 3. Click the edit button for this script from the Violentmonkey menu. * 4. Click on the "Values" tab for this script. * 5. Click on the configuration option you want to change and edit the value * (change to true or false). * 6. Click the save button. * 7. Refresh or visit the page to see the changes. * * In TamperMonkey: * 1. Install the user script. * 2. Let the script run at least once by loading an applicable url. * 3. From the TamperMonkey dashboard, click the "Settings" tab. * 4. Change the "Config mode" mode to "Advanced". * 5. On the "Installed userscripts" tab (dashboard), click the edit button * for this script. * 6. Click the "Storage" tab. If you don't see this tab be sure the config * mode is set to "Advanced" as described above. Also be sure that you have * visited an applicable page with the user script enabled first. * 7. Change the value for any desired configuration options (change to true * or false). * 8. Click the "Save" button. * 9. Refresh or visit the page to see the changes. If it doesn't seem to be * working, refresh the TamperMonkey dashboard to double check your change * has stuck. If not try again and click the save button. * * Other user script managers: * 1. Change any of the options below directly in the code editor and save. * 2. Whenever you update this script or reinstall it you will have to make * your changes again. */ const defaultConfig = { /** When true, adds the AniList average score to the header. */ addAniListScore: true, /** When true, adds the MyAnimeList score to the header. */ addMyAnimeListScore: true, /** When true, adds the Kitsu score to the header. */ addKitsuScore: false, /** When true, adds the Shikimori score to the header. */ addShikimoriScore: false, /** When true, show the smile/neutral/frown icons next to the AniList score. */ showIconWithAniListScore: true, /** * When true, show AniList's "Mean Score" instead of the "Average Score". * Regardless of this value, if the "Average Score" is not available * then the "Mean Score" will be shown. */ preferAniListMeanScore: false, /** When true, shows loading indicators when scores are being retrieved. */ showLoadingIndicators: true, /** * Sets the main title to display. * Possible values: 'native', 'romaji', 'english'. */ mainTitle: 'romaji', /** * Sets the secondary title to display. * Possible values: 'native', 'romaji', 'english'. */ secondaryTitle: 'english', /** Maximum number of tags to show initially */ maxVisibleTags: 10, }; /** * Constants for this user script. */ const constants = { /** Endpoint for the AniList API */ ANI_LIST_API: 'https://graphql.anilist.co', /** Endpoint for the MyAnimeList API */ MAL_API: 'https://api.jikan.moe/v4', /** Endpoint for the Kitsu API */ KITSU_API: 'https://kitsu.io/api/edge', SHIKI_API: 'https://shikimori.one/api', /** Regex to extract the page type and media id from a AniList url path */ ANI_LIST_URL_PATH_REGEX: /(anime|manga)\/([0-9]+)/i, /** Prefix message for logs to the console */ LOG_PREFIX: '[AniList Unlimited User Script]', /** Prefix for class names added to created elements (prevent conflicts) */ CLASS_PREFIX: 'kln', /** Title suffix added to created elements (for user information) */ CUSTOM_ELEMENT_TITLE: '(by kln)', /** When true, output additional logs to the console */ DEBUG: false, /** Maximum number of tags to show initially */ MAX_TAGS_VISIBLE: 10, SEPERATOR: ' ยท ', SPOILER_TAG_SELECTOR: '.spoiler-toggle', SHOW_MORE_TEXT: ' more', COLLAPSE_TEXT: 'Collapse', }; /** * User script manager functions. * * Provides compatibility between Tampermonkey, Greasemonkey 4+, etc... */ const userScriptAPI = (() => { const api = {}; if (typeof GM_xmlhttpRequest !== 'undefined') { api.GM_xmlhttpRequest = GM_xmlhttpRequest; } else if ( typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined' ) { api.GM_xmlhttpRequest = GM.xmlHttpRequest; } if (typeof GM_setValue !== 'undefined') { api.GM_setValue = GM_setValue; } else if ( typeof GM !== 'undefined' && typeof GM.setValue !== 'undefined' ) { api.GM_setValue = GM.setValue; } if (typeof GM_getValue !== 'undefined') { api.GM_getValue = GM_getValue; } else if ( typeof GM !== 'undefined' && typeof GM.getValue !== 'undefined' ) { api.GM_getValue = GM.getValue; } /** whether GM_xmlhttpRequest is supported. */ api.supportsXHR = typeof api.GM_xmlhttpRequest !== 'undefined'; /** whether GM_setValue and GM_getValue are supported. */ api.supportsStorage = typeof api.GM_getValue !== 'undefined' && typeof api.GM_setValue !== 'undefined'; return api; })(); /** * Utility functions. */ const utils = { /** * Logs an error message to the console. * * @param {string} message - The error message. * @param {...any} additional - Additional values to log. */ error(message, ...additional) { console.error(`${constants.LOG_PREFIX} Error: ${message}`, ...additional); }, /** * Logs a group of related error messages to the console. * * @param {string} label - The group label. * @param {...any} additional - Additional error messages. */ groupError(label, ...additional) { console.groupCollapsed(`${constants.LOG_PREFIX} Error: ${label}`); additional.forEach(entry => { console.log(entry); }); console.groupEnd(); }, /** * Logs a debug message which only shows when constants.DEBUG = true. * * @param {string} message The message. * @param {...any} additional - ADditional values to log. */ debug(message, ...additional) { if (constants.DEBUG) { console.debug(`${constants.LOG_PREFIX} ${message}`, ...additional); } }, /** * Makes an XmlHttpRequest using the user script util. * * Common options include the following: * * - url (url endpoint, e.g., https://api.endpoint.com) * - method (e.g., GET or POST) * - headers (an object containing headers such as Content-Type) * - responseType (e.g., 'json') * - data (body data) * * See https://wiki.greasespot.net/GM.xmlHttpRequest for other options. * * If `options.responseType` is set then the response data is returned, * otherwise `responseText` is returned. * * @param {Object} options - The request options. * * @returns A Promise that resolves with the response or rejects on any * errors or status code other than 200. */ xhr(options) { return new Promise((resolve, reject) => { const xhrOptions = Object.assign({}, options, { onabort: res => reject(res), ontimeout: res => reject(res), onerror: res => reject(res), onload: res => { if (res.status === 200) { if (options.responseType && res.response) { resolve(res.response); } else { resolve(res.responseText); } } else { reject(res); } }, }); userScriptAPI.GM_xmlhttpRequest(xhrOptions); }); }, /** * Waits for an element to load. * * @param {string} selector - Wait for the element matching this * selector to be found. * @param {Element} [container=document] - The root element for the * selector, defaults to `document`. * @param {number} [timeoutSecs=7] - The number of seconds to wait * before timing out. * * @returns {Promise} A Promise returning the DOM element, or a * rejection if a timeout occurred. */ async waitForElement(selector, container = document, timeoutSecs = 7) { const element = container.querySelector(selector); if (element) { return Promise.resolve(element); } return new Promise((resolve, reject) => { const timeoutTime = Date.now() + timeoutSecs * 1000; const handler = () => { const element = document.querySelector(selector); if (element) { resolve(element); } else if (Date.now() > timeoutTime) { reject(new Error(`Timed out waiting for selector '${selector}'`)); } else { setTimeout(handler, 100); } }; setTimeout(handler, 1); }); }, /** * Waits for an element to load. * * @param {string} selector - Wait for the element matching this * selector to be found. * @param {Element} [container=document] - The root element for the * selector, defaults to `document`. * @param {string} text - The text content the element needs to have. * @param {number} [timeoutSecs=7] - The number of seconds to wait * before timing out. * * @returns {Promise} A Promise returning the DOM element, or a * rejection if a timeout occurred. */ async waitForElementWithText(selector, container = document, text, timeoutSecs = 7) { return new Promise((resolve, reject) => { const timeoutTime = Date.now() + timeoutSecs * 1000; const handler = () => { const elements = container.querySelectorAll(selector); let element = null; for (const el of elements) { if (el.textContent.trim() === text) { element = el; break; } } if (element) { resolve(element); } else if (Date.now() > timeoutTime) { reject(new Error(`Timed out waiting for selector '${selector}' with text '${text}'`)); } else { setTimeout(handler, 100); } }; setTimeout(handler, 1); }); }, /** * Removes all children from a given DOM element. * @param {HTMLElement} element */ clearElement(element) { while (element.firstChild) { element.removeChild(element.firstChild) } }, /** * Loads user configuration from storage. * * @param {Object} defaultConfiguration - An object containing all of * the user configuration keys mapped to their default values. This * object will be used to set an initial value for any keys not currently * in storage. * * @param {Boolean} [setDefault=true] - When true, save the value from * defaultConfiguration for keys not present in storage for next time. * This lets the user edit the configuration more easily. * * @returns {Promise} A Promise returning an object that has the * config from storage, or an empty object if the storage APIs are not * defined. */ async loadUserConfiguration(defaultConfiguration, setDefault = true) { if (!userScriptAPI.supportsStorage) { utils.debug('User configuration is not enabled'); return {}; } const userConfig = {}; for (let [key, value] of Object.entries(defaultConfiguration)) { const userValue = await userScriptAPI.GM_getValue(key); // init any config values that haven't been set if (setDefault && userValue === undefined) { utils.debug(`setting default config value for ${key}: ${value}`); await userScriptAPI.GM_setValue(key, value); } else { userConfig[key] = userValue; } } utils.debug('loaded user configuration from storage', userConfig); return userConfig; }, }; /** * Functions to make API calls. */ const api = { /** * Loads data from the AniList API. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} aniListId - The AniList media id. * * @returns {Promise} A Promise returning the media's data, or a * rejection if there was a problem calling the API. */ async fetchAniListData(type, aniListId) { var query = ` query ($id: Int, $type: MediaType) { Media (id: $id, type: $type) { idMal averageScore meanScore title { english romaji } } } `; const variables = { id: aniListId, type: type.toUpperCase(), }; try { const response = await utils.xhr({ url: constants.ANI_LIST_API, method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, responseType: 'json', data: JSON.stringify({ query, variables, }), }); utils.debug('AniList API response:', response); return response.data.Media; } catch (res) { const message = `AniList API request failed for media with ID '${aniListId}'`; utils.groupError( message, `Request failed with status ${res.status}`, ...(res.response ? res.response.errors : [res]) ); const error = new Error(message); error.response = res; throw error; } }, /** * Loads data from the MyAnimeList API. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} myAnimeListId - The MyAnimeList media id. * * @returns {Promise} A Promise returning the media's data, or a * rejection if there was a problem calling the API. */ async fetchMyAnimeListData(type, myAnimeListId) { try { const response = await utils.xhr({ url: `${constants.MAL_API}/${type}/${myAnimeListId}`, method: 'GET', responseType: 'json', }); utils.debug('MyAnimeList API response:', response); return response.data; } catch (res) { const message = `MyAnimeList API request failed for mapped MyAnimeList ID '${myAnimeListId}'`; utils.groupError( message, `Request failed with status ${res.status}`, res.response ? res.response.error || res.response.message : res ); const error = new Error(message); error.response = res; throw error; } }, /** * Loads data from the Shikimori API. * */ async fetchShikimoriData(pageType, shikimoriId) { let type; try { type = pageType === 'anime' ? 'animes' : 'mangas'; const response = await utils.xhr({ url: `${constants.SHIKI_API}/${type}/${shikimoriId}`, method: 'GET', responseType: 'json', }); utils.debug('Shikimori API response:', response); return response; } catch (res) { const message = `Shikimori API request failed for mapped Shikimori ID '${shikimoriId}'`; utils.groupError( message, `Request failed with status ${res.status}`, res.response ? res.response.error || res.response.message : res ); const error = new Error(message); error.response = res; throw error; } }, /** * Loads data from the Kitsu API. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} englishTitle - Search for media with this title. * @param {string} romajiTitle - Search for media with this title. * * @returns {Promise} A Promise returning the media's data, or a * rejection if there was a problem calling the API. */ async fetchKitsuData(type, englishTitle, romajiTitle) { try { const fields = 'slug,averageRating,userCount,titles'; const response = await utils.xhr({ url: encodeURI( `${constants.KITSU_API }/${type}?page[limit]=3&fields[${type}]=${fields}&filter[text]=${englishTitle || romajiTitle }` ), method: 'GET', headers: { Accept: 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', }, responseType: 'json', }); utils.debug('Kitsu API response:', response); if (response.data && response.data.length) { let index = 0; let isExactMatch = false; const collator = new Intl.Collator({ usage: 'search', sensitivity: 'base', ignorePunctuation: true, }); const matchedIndex = response.data.findIndex(result => { return Object.values(result.attributes.titles).find(kitsuTitle => { return ( collator.compare(englishTitle, kitsuTitle) === 0 || collator.compare(romajiTitle, kitsuTitle) === 0 ); }); }); if (matchedIndex > -1) { utils.debug( `matched title for Kitsu result at index ${matchedIndex}`, response.data[index] ); index = matchedIndex; isExactMatch = true; } else { utils.debug('exact title match not found in Kitsu results'); } return { isExactMatch, data: response.data[index].attributes, }; } else { utils.debug(`Kitsu API returned 0 results for '${englishTitle}'`); return {}; } } catch (res) { const message = `Kitsu API request failed for text '${englishTitle}'`; utils.groupError( message, `Request failed with status ${res.status}`, ...(res.response ? res.response.errors : []) ); const error = new Error(message); error.response = res; throw error; } }, }; /** * AniList SVGs. */ const svg = { /** from AniList */ smile: '', /** from AniList */ neutral: '', /** from AniList */ frown: '', /** From https://github.com/SamHerbert/SVG-Loaders */ // License/accreditation https://github.com/SamHerbert/SVG-Loaders/blob/master/LICENSE.md loading: '', mal: '', shiki: '', kitsu: `` }; /** * Handles manipulating the current AniList page. */ class AniListPage { constructor(config) { this.selectors = { pageTitle: 'head > title', header: '.page-content .header .content', headerContainer: '.page-content .header .container', sidebar: '.page-content .sidebar', genresContainer: '.data-set.data-list .type', tagsContainer: '.tags', tagItem: '.tag', genreItem: '.data-set.data-list .value a', pageBanner: '.page-content .container .banner', pageContent: '.page-content', pageContainer: '.page-content .header .container', romajiTitle: '.data-set .type', nativeTitle: '.data-set .type', h1Title: '.page-content .header .content h1', coverWrap: '.page-content .header .cover-wrap', content: '.page-content .header .content', }; this.headerCache = null; this.lastProcessedPath = null; this.config = config; } /** * init the page and apply page modifications. */ init() { utils.debug('initializing page'); this.applyModifications().catch(e => utils.error(`Unable to apply modifications to the page - ${e.message}`) ); // eslint-disable-next-line no-unused-vars const observer = new MutationObserver((mutations, observer) => { utils.debug('mutation observer', mutations); this.applyModifications().catch(e => utils.error( `Unable to apply modifications to the page - ${e.message}` ) ); }); const target = document.querySelector(this.selectors.pageTitle); observer.observe(target, { childList: true, characterData: true }); } /** * Applies modifications to the page based on config settings. * * This will only add content if we are on a relevant page in the app. */ async applyModifications() { const pathname = window.location.pathname; utils.debug('checking page url', pathname); if (this.lastProcessedPath === pathname) { utils.debug('url path did not change, skipping'); return; } this.lastProcessedPath = pathname; const matches = constants.ANI_LIST_URL_PATH_REGEX.exec(pathname); if (!matches) { utils.debug('url did not match'); return; } const pageType = matches[1]; const mediaId = matches[2]; utils.debug('pageType:', pageType, 'mediaId:', mediaId); const aniListData = await api.fetchAniListData(pageType, mediaId); if (this.config.addAniListScore) { this.addAniListScore(pageType, mediaId, aniListData); } if (this.config.addShikimoriScore) { this.addShikimoriScore(pageType, mediaId, aniListData); } if (this.config.addMyAnimeListScore) { this.addMyAnimeListScore(pageType, mediaId, aniListData); } if (this.config.addKitsuScore) { this.addKitsuScore(pageType, mediaId, aniListData); } await this.applyGenresAndTagsModifications(); await this.applyTitlesModifications(); this.correctGrid(); } /** * Adds the AniList score to the header. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} mediaId - The AniList media id. * @param {Object} aniListData - The data from the AniList api. */ async addAniListScore(pageType, mediaId, aniListData) { const slot = 1; const source = 'AniList'; let rawScore, info; if ( aniListData.meanScore && (this.config.preferAniListMeanScore || !aniListData.averageScore) ) { rawScore = aniListData.meanScore; info = ' (mean)'; } else if (aniListData.averageScore) { rawScore = aniListData.averageScore; info = ' (average)'; } const score = rawScore ? `${rawScore}%` : '(N/A)'; let iconMarkup; if (this.config.showIconWithAniListScore) { if (rawScore === null || rawScore == undefined) { iconMarkup = svg.neutral; } else if (rawScore >= 75) { iconMarkup = svg.smile; } else if (rawScore >= 60) { iconMarkup = svg.neutral; } else { iconMarkup = svg.frown; } } this.addScores({ slot, source, score, iconMarkup, info, href: `https://anilist.co/${pageType}/${mediaId}` }).catch(e => { utils.error( `Unable to add the ${source} score to the header: ${e.message}` ); }); } /** * Adds the MyAnimeList score to the header. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} mediaId - The AniList media id. * @param {Object} aniListData - The data from the AniList api. */ async addMyAnimeListScore(pageType, mediaId, aniListData) { const slot = 2; const source = 'MyAnimeList'; if (!aniListData.idMal) { utils.error(`no ${source} id found for media ${mediaId}`); return this.clearScoreSlot(slot); } if (this.config.showLoadingIndicators) { await this.showSlotLoading(slot); } api .fetchMyAnimeListData(pageType, aniListData.idMal) .then(data => { const score = data.score; const href = data.url; return this.addScores({ slot, source, score, href }); }) .catch(e => { utils.error( `Unable to add the ${source} score to the header: ${e.message}` ); // https://github.com/jikan-me/jikan-rest/issues/102 if (e.response && e.response.status === 503) { return this.addScores({ slot, source, score: 'Unavailable', info: ': The Jikan API is temporarily unavailable. Please try again later', }); } else if (e.response && e.response.status === 429) { // rate limited return this.addScores({ slot, source, score: 'Unavailable*', info: ': Temporarily unavailable due to rate-limiting, since you made too many requests to the MyAnimeList API. Reload in a few seconds to try again', }); } }); } async addShikimoriScore(pageType, mediaId, aniListData) { const slot = 3; const source = 'Shikimori'; if (!aniListData.idMal) { utils.error(`no ${source} id found for media ${mediaId}`); return this.clearScoreSlot(slot); } if (this.config.showLoadingIndicators) { await this.showSlotLoading(slot); } try { const data = await api.fetchShikimoriData(pageType, aniListData.idMal); if (!data || !data.rates_scores_stats || data.rates_scores_stats.length === 0) { utils.error(`No rates_scores_stats found in Shikimori API response for media ${mediaId} with idMal ${aniListData.idMal}`); return this.addScores({ slot, source, score: 'N/A' }); } const totalVotes = data.rates_scores_stats.reduce((sum, stat) => sum + stat.value, 0); const weightedSum = data.rates_scores_stats.reduce((sum, stat) => sum + stat.name * stat.value, 0); const averageScore = weightedSum / totalVotes; utils.debug(`score: ${averageScore}`); const score = averageScore.toFixed(2); const href = new URL(data.url, 'https://shikimori.one').href; return this.addScores({ slot, source, score, href }); } catch (e) { console.error(`Unable to add the ${source} score to the header:`, e); //... other error handling code... } } /** * Adds the Kitsu score to the header. * * @param {('anime'|'manga')} type - The type of media content. * @param {string} mediaId - The AniList media id. * @param {Object} aniListData - The data from the AniList api. */ async addKitsuScore(pageType, mediaId, aniListData) { const slot = 4; const source = 'Kitsu'; const englishTitle = aniListData.title.english; const romajiTitle = aniListData.title.romaji; if (!englishTitle && !romajiTitle) { utils.error( `Unable to search ${source} - no media title found for ${mediaId}` ); return this.clearScoreSlot(slot); } if (this.config.showLoadingIndicators) { await this.showSlotLoading(slot); } api .fetchKitsuData(pageType, englishTitle, romajiTitle) .then(entry => { if (!entry.data) { utils.error(`no ${source} matches found for media ${mediaId}`); return this.clearScoreSlot(slot); } const data = entry.data; let score = null; if (data.averageRating !== undefined && data.averageRating !== null) { score = `${data.averageRating}%`; if (!entry.isExactMatch) { score += '*'; } } const href = `https://kitsu.io/${pageType}/${data.slug}`; let info = ''; if (!entry.isExactMatch) { info += ', *exact match not found'; } const kitsuTitles = Object.values(data.titles).join(', '); info += `, matched on "${kitsuTitles}"`; return this.addScores({ slot, source, score, href, info, iconMarkup: svg.kitsu }); }) .catch(e => { utils.error( `Unable to add the ${source} score to the header: ${e.message}` ); }); } /** * Shows a loading indicator in the given slot position. * * @param {number} slot - The slot position. */ async showSlotLoading(slot) { const slotEl = await this.getScoreSlot(slot); if (slotEl) { slotEl.style.display = 'flex' slotEl.innerHTML = svg.loading; } } /** * Removes markup from the header for the given slot position. * * @param {number} slot - The slot position. */ async clearScoreSlot(slot) { const slotEl = await this.getScoreSlot(slot); if (slotEl) { while (slotEl.lastChild) { slotEl.removeChild(slotEl.lastChild); } slotEl.style.display = 'none' slotEl.style.marginRight = '0'; } } /** * Add score data to a slot in the header section. * * @param {Object} info - Data about the score. * @param {number} info.slot - The ordering position within the header. * @param {string} info.source - The source of the data. * @param {string} [info.score] - The score text. * @param {string} [info.href] - The link for the media from the source. * @param {string} [info.iconMarkup] - Icon markup representing the score. * @param {string} [info=''] - Additional info about the score. */ async addScores({ slot, source, score, href, iconMarkup, info = '' }) { const containerEl = await this.getScoreContainer(); let slotEl = await this.getScoreSlot(slot); if (slotEl) { let slotColor; if (document.body.classList.contains('site-theme-dark')) { slotColor = 'rgb(var(--color-gray-800))'; } else if (document.body.classList.contains('site-theme-contrast') || !document.body.classList.length) { slotColor = 'rgb(250, 250, 250)'; } else { slotColor = 'rgb(250, 250, 250)'; } slotEl.style.color = slotColor; slotEl.style.display = 'flex' const newSlotEl = slotEl.cloneNode(false); newSlotEl.title = `${source} Score${info} ${constants.CUSTOM_ELEMENT_TITLE}`; newSlotEl.style.display = 'flex'; newSlotEl.style.alignItems = 'center'; newSlotEl.style.gap = '0.3em'; const link = document.createElement('a'); link.href = href; link.title = `View this entry on ${source} ${constants.CUSTOM_ELEMENT_TITLE}`; link.style.display = 'flex'; // Changed to inline-flex link.style.textDecoration = 'none'; link.style.color = 'inherit'; if (source === 'MyAnimeList') { link.innerHTML = svg.mal; } else if (source === 'Shikimori') { link.innerHTML = svg.shiki; } else if (iconMarkup) { link.innerHTML = iconMarkup; } else { link.textContent = source; } newSlotEl.appendChild(link); const scoreEl = document.createElement('span'); scoreEl.style.fontWeight = '500'; scoreEl.append(document.createTextNode(score || 'No Score')); newSlotEl.appendChild(scoreEl); slotEl.replaceWith(newSlotEl); } else { throw new Error(`Unable to find element to place ${source} score`); } } /** * Gets the slot element at the given position. * * @param {number} slot - Get the slot element at this ordering position. */ async getScoreSlot(slot) { const containerEl = await this.getScoreContainer(); const slotClass = `${constants.CLASS_PREFIX}-slot${slot}`; return containerEl.querySelector(`.${slotClass}`); } /** * Gets the container for new content, adding it to the DOM if * necessary. */ async getScoreContainer() { const headerEl = await utils.waitForElement(this.selectors.header); const containerElement = await utils.waitForElement(this.selectors.headerContainer) const insertionPoint = containerElement.querySelector('.page-content > div:nth-child(1)') || containerElement.firstElementChild; const containerClass = `${constants.CLASS_PREFIX}-scores`; let containerEl = containerElement.querySelector(`.${containerClass}`); if (!containerEl) { containerEl = document.createElement('div'); containerEl.className = containerClass; containerEl.style.display = 'flex'; // Ensure horizontal layout containerEl.style.marginBottom = '1em'; containerEl.style.alignItems = 'flex-end'; containerEl.style.justifyContent = 'flex-end'; containerEl.style.gridArea = 'rates'; containerEl.style.gap = '1em'; containerEl.style.height = '100px'; containerEl.style.zIndex = '2' containerEl.style.flexWrap = 'wrap'; // Allow wrapping if needed const numSlots = 4; for (let i = 0; i < numSlots; i++) { const slotEl = document.createElement('div'); slotEl.className = `${constants.CLASS_PREFIX}-slot${i + 1}`; slotEl.style.display = 'none' containerEl.appendChild(slotEl); } insertionPoint.insertAdjacentElement('afterend', containerEl); } return containerEl; } async applyGenresAndTagsModifications() { try { const headerEl = await utils.waitForElement(this.selectors.header); const sidebarElement = await utils.waitForElement(this.selectors.sidebar); const genresContainer = await utils.waitForElementWithText(this.selectors.genresContainer, sidebarElement, "Genres"); const genres = genresContainer.parentElement; const tagsContainer = await utils.waitForElement(this.selectors.tagsContainer, sidebarElement); const insertionPoint = headerEl.querySelector('h1') || headerEl.firstElementChild; const containerClass = `${constants.CLASS_PREFIX}-genres-tags`; let containerEl = headerEl.querySelector(`.${containerClass}`); if (!containerEl) { containerEl = document.createElement('div'); containerEl.className = containerClass; containerEl.style.display = 'flex'; containerEl.style.flexDirection = 'column'; containerEl.style.marginTop = '2em'; containerEl.style.alignItems = 'flex-start'; insertionPoint.insertAdjacentElement('afterend', containerEl); } else { utils.clearElement(containerEl); } if (genres) { const genreList = genres.querySelectorAll(this.selectors.genreItem) const formattedGenres = document.createElement('div'); formattedGenres.title = `Genres ${constants.CUSTOM_ELEMENT_TITLE}`; formattedGenres.style.marginBottom = '0.5em' genreList.forEach(genreLink => { const genreWrapper = document.createElement('span'); const genreText = genreLink.textContent.trim() genreWrapper.style.fontWeight = 'bold'; const genreLinkClone = genreLink.cloneNode(true); genreLinkClone.textContent = genreText genreWrapper.appendChild(genreLinkClone) formattedGenres.appendChild(genreWrapper); if (genreLink !== genreList[genreList.length - 1]) { formattedGenres.append(constants.SEPERATOR) } }) containerEl.appendChild(formattedGenres); } if (tagsContainer) { const tagElements = Array.from(tagsContainer.querySelectorAll(this.selectors.tagItem)); const formattedTags = document.createElement('div'); formattedTags.title = `Search this tag ${constants.CUSTOM_ELEMENT_TITLE}`; formattedTags.style.display = 'flex'; formattedTags.style.flexWrap = 'wrap'; formattedTags.style.fontSize = '0.85em'; formattedTags.style.columnGap = '1em'; formattedTags.style.rowGap = '0.5em'; const visibleTags = []; const hiddenTags = []; tagElements.forEach((tagElement, index) => { const nameLink = tagElement.querySelector('a.name'); const rankElement = tagElement.querySelector('.rank'); if (!nameLink || !rankElement) { return; } const name = nameLink.textContent.trim(); const rank = rankElement.textContent.trim(); const tagSpan = document.createElement('span'); const isSpoiler = tagElement.closest(constants.SPOILER_TAG_SELECTOR) !== null; const tagLinkClone = nameLink.cloneNode(true); tagLinkClone.textContent = name; const rankSpan = document.createElement('span'); rankSpan.textContent = constants.SEPERATOR + rank; rankSpan.classList.add(`${constants.CLASS_PREFIX}-tag-rank`); tagSpan.appendChild(tagLinkClone); tagSpan.appendChild(rankSpan); if (isSpoiler) { tagSpan.classList.add(`${constants.CLASS_PREFIX}-spoiler-tag`); } if (index < this.config.maxVisibleTags) { visibleTags.push(tagSpan); } else { tagSpan.classList.add(`${constants.CLASS_PREFIX}-hidden-tag`); hiddenTags.push(tagSpan); } }); visibleTags.forEach(tag => formattedTags.appendChild(tag)) let showMoreButton = null; if (hiddenTags.length > 0) { showMoreButton = document.createElement('span'); showMoreButton.textContent = `${hiddenTags.length}${constants.SHOW_MORE_TEXT}`; showMoreButton.classList.add(`${constants.CLASS_PREFIX}-show-more-tags`, `${constants.CLASS_PREFIX}-tag-rank`); let isExpanded = false; showMoreButton.addEventListener('click', () => { isExpanded = !isExpanded; if (isExpanded) { hiddenTags.forEach(tag => { tag.classList.remove(`${constants.CLASS_PREFIX}-hidden-tag`); tag.classList.add(`${constants.CLASS_PREFIX}-visible-tag`); formattedTags.appendChild(tag); }); formattedTags.appendChild(showMoreButton); showMoreButton.textContent = constants.COLLAPSE_TEXT; } else { hiddenTags.forEach(tag => { tag.classList.add(`${constants.CLASS_PREFIX}-hidden-tag`); tag.classList.remove(`${constants.CLASS_PREFIX}-visible-tag`) formattedTags.insertBefore(tag, showMoreButton) }); showMoreButton.textContent = `${hiddenTags.length}${constants.SHOW_MORE_TEXT}`; } }); formattedTags.appendChild(showMoreButton); hiddenTags.forEach(tag => { formattedTags.insertBefore(tag, showMoreButton); }) hiddenTags.forEach(tag => tag.classList.add(`${constants.CLASS_PREFIX}-hidden-tag`)) } containerEl.appendChild(formattedTags) } } catch (error) { utils.error("Unable to move or format genres or tags: ", error); } } async applyTitlesModifications() { try { const headerContainer = await utils.waitForElement(this.selectors.headerContainer); const sidebarElement = await utils.waitForElement(this.selectors.sidebar); const h1Title = await utils.waitForElement(this.selectors.h1Title, headerContainer); const romajiTitleContainer = await utils.waitForElementWithText(this.selectors.romajiTitle, sidebarElement, "Romaji"); const nativeTitleContainer = await utils.waitForElementWithText(this.selectors.nativeTitle, sidebarElement, "Native"); let romajiTitle = romajiTitleContainer?.nextElementSibling?.textContent?.trim() || ''; let nativeTitle = nativeTitleContainer?.nextElementSibling?.textContent?.trim() || ''; const containerClass = `${constants.CLASS_PREFIX}-titles`; let containerEl = headerContainer.querySelector(`.${containerClass}`); if (!containerEl) { containerEl = document.createElement('div'); containerEl.className = containerClass; containerEl.style.gridArea = 'header'; containerEl.style.zIndex = '2'; headerContainer.insertBefore(containerEl, headerContainer.firstChild); } else { utils.clearElement(containerEl); } const formattedTitles = document.createElement('div'); formattedTitles.title = `Copy title ${constants.CUSTOM_ELEMENT_TITLE}`; formattedTitles.style.display = 'flex'; formattedTitles.style.flexDirection = 'column'; formattedTitles.style.gap = '0.3em'; formattedTitles.style.marginTop = '40px'; formattedTitles.style.color = 'rgb(var(--color-gray-600))'; formattedTitles.style.height = '4.5em'; formattedTitles.style.justifyContent = 'flex-end'; const mainTitleSpan = document.createElement('span'); let mainTitleSpanColor; if (document.body.classList.contains('site-theme-dark')) { mainTitleSpanColor = 'rgb(var(--color-gray-800))'; } else if (document.body.classList.contains('site-theme-contrast') || !document.body.classList.length) { mainTitleSpanColor = 'rgb(250, 250, 250)'; } else { mainTitleSpanColor = 'rgb(250, 250, 250)'; } mainTitleSpan.style.color = mainTitleSpanColor; mainTitleSpan.style.fontWeight = '800'; mainTitleSpan.style.fontSize = '2rem'; mainTitleSpan.style.letterSpacing = '0.03em'; mainTitleSpan.style.fontFamily = "'Overpass'"; const secondaryTitleSpan = document.createElement('span'); let mainTitleText, secondaryTitleText; if (this.config.mainTitle === 'native') { mainTitleText = nativeTitle; } else if (this.config.mainTitle === 'english') { const aniListData = await api.fetchAniListData( constants.ANI_LIST_URL_PATH_REGEX.exec(window.location.pathname)[1], constants.ANI_LIST_URL_PATH_REGEX.exec(window.location.pathname)[2] ); mainTitleText = aniListData?.title?.english || romajiTitle; } else { mainTitleText = romajiTitle; } if (this.config.secondaryTitle === 'native') { secondaryTitleText = nativeTitle; } else if (this.config.secondaryTitle === 'english') { const aniListData = await api.fetchAniListData( constants.ANI_LIST_URL_PATH_REGEX.exec(window.location.pathname)[1], constants.ANI_LIST_URL_PATH_REGEX.exec(window.location.pathname)[2] ); secondaryTitleText = aniListData?.title?.english || romajiTitle; } else { secondaryTitleText = romajiTitle; } if (mainTitleText === secondaryTitleText) { secondaryTitleText = null; } mainTitleSpan.textContent = mainTitleText; if (secondaryTitleText) { secondaryTitleSpan.textContent = secondaryTitleText; } const mainTitleLink = document.createElement('a'); mainTitleLink.style.textDecoration = 'none'; mainTitleLink.style.color = 'inherit'; mainTitleLink.appendChild(mainTitleSpan); mainTitleLink.addEventListener('click', (event) => { event.preventDefault(); navigator.clipboard.writeText(mainTitleText) .then(() => utils.debug('Main Title copied to clipboard')) .catch(err => utils.error('Could not copy main title:', err)); }); const secondaryTitleLink = document.createElement('a'); secondaryTitleLink.style.textDecoration = 'none'; secondaryTitleLink.style.color = 'inherit'; if (secondaryTitleText) { secondaryTitleLink.appendChild(secondaryTitleSpan); } secondaryTitleLink.addEventListener('click', (event) => { event.preventDefault(); navigator.clipboard.writeText(secondaryTitleText) .then(() => utils.debug('Secondary Title copied to clipboard')) .catch(err => utils.error('Could not copy secondary title:', err)); }); formattedTitles.appendChild(mainTitleLink); if (secondaryTitleText) { formattedTitles.appendChild(secondaryTitleLink); } containerEl.appendChild(formattedTitles); h1Title.style.display = 'none'; } catch (error) { utils.error("Unable to move or format titles: ", error); } } correctGrid() { try { const container = document.querySelector(this.selectors.pageContainer); const coverWrap = document.querySelector(this.selectors.coverWrap); const content = document.querySelector(this.selectors.content); } catch (error) { utils.error("Unable to fix grid: ", error) } } } // execution: // check for compatibility if (!userScriptAPI.supportsXHR) { utils.error( 'The current version of your user script manager ' + 'does not support required features. Please update ' + 'it to the latest version and try again.' ); return; } // setup configuration const userConfig = await utils.loadUserConfiguration(defaultConfig); const config = Object.assign({}, defaultConfig, userConfig); utils.debug('configuration values:', config); const page = new AniListPage(config); page.init(); const style = document.createElement('style'); style.textContent = ` .${constants.CLASS_PREFIX}-spoiler-tag { filter: blur(4px); transition: all 0.3s cubic-bezier(0, 0, 0.23, 1); } .${constants.CLASS_PREFIX}-spoiler-tag:hover { filter: none; } .${constants.CLASS_PREFIX}-tag-rank { font-weight: lighter; opacity: 0.7; } .${constants.CLASS_PREFIX}-hidden-tag { display: none; } .${constants.CLASS_PREFIX}-show-more-tags { cursor: pointer; display: inline-flex; align-items: center; white-space: nowrap; margin-bottom: 0.5em; } .${constants.CLASS_PREFIX}-show-more-tags:hover { text-decoration: underline; } .media-page-unscoped.media-manga .banner::before, .media-page-unscoped.media-anime .banner::before { content: ""; display: block; position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 2; background: linear-gradient(to top, rgba(0, 0, 0, .85), transparent 180px), rgba(0, 0, 0, 0); } .media-page-unscoped.media-manga .banner, .media-page-unscoped.media-anime .banner { margin-bottom: -8em; z-index: 1; position: relative; } .media-page-unscoped.media-manga .header-wrap .shadow, .media-page-unscoped.media-anime .header-wrap .shadow { display: none; } .${constants.CLASS_PREFIX}-titles span:hover { cursor: pointer; } .media-page-unscoped.media-manga .header .container, .media-page-unscoped.media-anime .header .container { display: grid; grid-column-gap: 30px; grid-template-columns: 215px 1fr auto; grid-template-areas: "cover header rates" "cover content content"; } .media-page-unscoped.media-manga .header .cover-wrap, .media-page-unscoped.media-anime .header .cover-wrap { grid-area: cover; z-index: 2; margin-top: 16px; } .media-page-unscoped.media-manga .header .content, .media-page-unscoped.media-anime .header .content { grid-area: content; padding-top: 0; } .media .hohDownload {z-index: 5;} @media (max-width: 760px) { .media-page-unscoped.media-manga .banner::before, .media-page-unscoped.media-anime .banner::before { background: radial-gradient(circle at center -30%, rgba(10, 10, 10, 0.2) 0, #0A0A0A 100%); } .media-page-unscoped.media-manga .header .container, .media-page-unscoped.media-anime .header .container { display: grid; grid-column-gap: 30px; grid-template-columns: auto; grid-template-areas: "cover" "header" "rates" "content"; } .media-page-unscoped.media-manga .cover-wrap-inner, .media-page-unscoped.media-anime .cover-wrap-inner { display: flex !important; flex-direction: column; grid-gap: 0 !important; } .media-page-unscoped.media-manga .cover-wrap-inner .cover, .media-page-unscoped.media-anime .cover-wrap-inner .cover { max-width: 200px !important; margin: 0 auto; } .media-page-unscoped.media-anime .cover-wrap-inner .actions, .media-page-unscoped.media-manga .cover-wrap-inner .actions { max-width: 500px; width: 100%; margin: 20px auto; } .${constants.CLASS_PREFIX}-genres-tags { margin-top: 0.5em !important; } .${constants.CLASS_PREFIX}-scores { margin-top: 1.4em; height: max-content !important; justify-content: flex-start !important; color: rgb(var(--color-gray-800)) !important; } .${constants.CLASS_PREFIX}-titles div { height: max-content !important; } .${constants.CLASS_PREFIX}-titles span { color: rgb(var(--color-gray-600)) !important; } .${constants.CLASS_PREFIX}-titles > div > a:nth-child(1) span{ color: rgb(var(--color-gray-800)) !important; } .media-page-unscoped.media-manga .banner, .media-page-unscoped.media-anime .banner { height: 400px; margin-bottom: -25em; } } `; document.head.appendChild(style); })();