Greasy Fork is available in English.

Average Reviews Calculator for MAL

Have a better reviews page with this script that loads all reviews on a single page instead of needing to navigate to the next web pages, and shows the averages of all score categories, if you want to sort the reviews with the same category click on the "Sort Reviews" button.

Εγκατάσταση αυτού του κώδικαΒοήθεια
Κώδικας προτεινόμενος από τον δημιιουργό

Μπορεί, επίσης, να σας αρέσει ο κώδικας Export Your Anime/Manga XML List On Any Page + Wayback Machine Your Profile..

Εγκατάσταση αυτού του κώδικα
  1. // ==UserScript==
  2. // @name Average Reviews Calculator for MAL
  3. // @namespace Transform MAL into Rotten Tomatoes
  4. // @version 12
  5. // @description Have a better reviews page with this script that loads all reviews on a single page instead of needing to navigate to the next web pages, and shows the averages of all score categories, if you want to sort the reviews with the same category click on the "Sort Reviews" button.
  6. // @author Only_Brad
  7. // @include /^https:\/\/myanimelist\.net\/(?:anime|manga)\/[\d]+\/.*\/reviews\/?(?:#!)?/
  8. // @icon https://www.google.com/s2/favicons?domain=myanimelist.net
  9. // @run-at document-end
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. const
  15. REVIEW_TAB_SELECTOR = "#content > table > tbody > tr > td:nth-child(2) > div.js-scrollfix-bottom-rel",
  16. REVIEWS_SELECTOR = `${REVIEW_TAB_SELECTOR} .borderDark`,
  17. EPISODE_WATCHED_SELECTOR = ".lightLink.spaceit",
  18. REVIEW_SCORE_TABLE_SELECTOR = ".textReadability tr td:nth-child(2)",
  19. MORE_REVIEWS_BUTTON_SELECTOR = "div.ml4 > a",
  20. OPTIONS_SELECTOR = ".reviews-horiznav-nav-sort-block",
  21. SEPARATOR_SELECTOR = ".reviews-horiznav-nav-sort-block",
  22. PRELIMINARY_CHECKBOX_SELECTOR = ".mr12.btn-checkbox.js-reviews-chk-preliminary";
  23.  
  24. const
  25. SELECT_ID = "sort-by-select",
  26. ONLY_PRELIMINARY_REVIEWS_ID = "only-preliminary-reviews-select",
  27. LOADING_SCREEN_ID = "loading-screen-reviews",
  28. AVERAGE_SCORES_ID = "average-scores-container";
  29.  
  30. const PAGE_REGEX = /(.*)\?p=([\d]*)/;
  31. const EPISODE_WATCHED_REGEX = /(\d+) of (\d+|\?)/;
  32.  
  33. const MAX_CONCURRENT_DOWNLOAD = 10;
  34. const REVIEWS_PER_PAGE = 20;
  35.  
  36. const domparser = new DOMParser();
  37.  
  38. /**
  39. * @typedef AnimeScore
  40. * @property {number} overall
  41. * @property {number} story
  42. * @property {number} animation
  43. * @property {number} sound
  44. * @property {number} character
  45. * @property {number} enjoyment
  46. * @property {boolean} reviewerHasCompleted
  47.  
  48. * @typedef MangaScore
  49. * @property {number} overall
  50. * @property {number} story
  51. * @property {number} art
  52. * @property {number} character
  53. * @property {number} enjoyment
  54. * @property {boolean} reviewerHasCompleted
  55. */
  56.  
  57. /**
  58. * @return {number}
  59. */
  60. function getCurrentPageNumber() {
  61. const url = window.location.href;
  62. const match = url.match(PAGE_REGEX);
  63.  
  64. if (!match) return 1;
  65. return parseInt(match[2]);
  66. }
  67.  
  68. /**
  69. * @return {string}
  70. */
  71. function getFirstPageUrl() {
  72. let url = window.location.href;
  73. if (url.endsWith("#!")) url = url.slice(0, url.length - 2);
  74. const match = url.match(PAGE_REGEX);
  75.  
  76. if (!match) return url;
  77. return match[1];
  78. }
  79.  
  80. /**
  81. *
  82. * Checks if a specific review page actually contains reviews.
  83. *
  84. * @param {Document} document
  85. * @return {boolean}
  86. */
  87. function hasReviews(document) {
  88. const reviewTab = document.querySelector(REVIEW_TAB_SELECTOR);
  89. if (!reviewTab) return false;
  90. const reviewScoreTables = reviewTab.querySelectorAll(REVIEW_SCORE_TABLE_SELECTOR);
  91. if (reviewScoreTables.length === 0) return false;
  92. return true;
  93. }
  94.  
  95. /**
  96. * @return {"anime"|"manga"}
  97. */
  98. function getMediaType() {
  99. const url = window.location.href;
  100. return url.split("/")[3];
  101. }
  102.  
  103. /**
  104. * Returns an object containing all the scores of a specific review and whether or not the reviewer has completed this series.
  105. *
  106. * Object form:
  107. * {overall: number, story: number, sound: number, character: number, enjoyment: number, reviewerHasCompleted: boolean}
  108. *
  109. * @param {HTMLElement} review
  110. * @return {AnimeScore|MangaScore}
  111. */
  112. function getScoreTable(review) {
  113. /** @type {AnimeScore | MangaScore} */
  114. const scores = {};
  115. const mediaType = getMediaType();
  116. const scoresValues = [...review.querySelectorAll(REVIEW_SCORE_TABLE_SELECTOR)]
  117. .map(td => parseInt(td.textContent));
  118.  
  119. switch (mediaType) {
  120. case "anime": {
  121. scores.overall = scoresValues[0];
  122. scores.story = scoresValues[1];
  123. scores.animation = scoresValues[2];
  124. scores.sound = scoresValues[3];
  125. scores.character = scoresValues[4];
  126. scores.enjoyment = scoresValues[5];
  127. scores.reviewerHasCompleted = getReviewerHasCompleted(review);
  128. break;
  129. }
  130. case "manga": {
  131. scores.overall = scoresValues[0];
  132. scores.story = scoresValues[1];
  133. scores.art = scoresValues[2];
  134. scores.character = scoresValues[3];
  135. scores.enjoyment = scoresValues[4];
  136. scores.reviewerHasCompleted = getReviewerHasCompleted(review);
  137. }
  138. }
  139.  
  140. return scores;
  141. }
  142.  
  143. /**
  144. *
  145. * @param {HTMLElement} review
  146. */
  147. function getReviewerHasCompleted(review) {
  148. const episodeWatched = review.querySelector(EPISODE_WATCHED_SELECTOR).textContent;
  149. const match = episodeWatched.match(EPISODE_WATCHED_REGEX);
  150.  
  151. if (!match) return false
  152.  
  153. return match[1] === match[2];
  154. }
  155.  
  156. /**
  157. * Extract the review html elements from one or more review pages.
  158. *
  159. * @param {Document[] | Document} documents
  160. * @return {HTMLElement[]}
  161. */
  162. function getReviews(documents) {
  163. if (!Array.isArray(documents)) documents = [documents];
  164. const allReviews = [];
  165.  
  166. documents.forEach(document => {
  167. const reviews = [...document.querySelectorAll(REVIEWS_SELECTOR)];
  168. allReviews.push(reviews);
  169. });
  170. return allReviews.flat();
  171. }
  172.  
  173. /**
  174. *
  175. * Get the html page of reviews of a specific anime.
  176. *
  177. * @param {number} page
  178. * @return {Promise<Document>}
  179. */
  180. async function fetchReviewsPage(page) {
  181. //If the program is trying to fetch the page we are currently on, simply return the document.
  182. if (page === getCurrentPageNumber()) return document;
  183. const url = `${getFirstPageUrl()}?p=${page}`;
  184. try {
  185. const response = await fetch(url);
  186. const text = await response.text();
  187. return domparser.parseFromString(text, "text/html");
  188. } catch (err) {
  189. console.error(err);
  190. }
  191. }
  192.  
  193. /**
  194. * Get all the html pages of reviews of a specific anime.
  195. *
  196. * @param {boolean} skipCurrentPage
  197. * @return {Promise<Document[]>}
  198. */
  199. async function fetchAllReviewsPages() {
  200. const currentPageNumber = getCurrentPageNumber();
  201. const reviewDocuments = [];
  202. let promises = [];
  203.  
  204. for (let i = 1;; i++) {
  205. for (let j = 1; j <= MAX_CONCURRENT_DOWNLOAD * i; j++) {
  206. if (j === currentPageNumber) continue;
  207. promises.push(fetchReviewsPage(j));
  208. }
  209. try {
  210. const documents = await Promise.all(promises);
  211. for (const currentDocument of documents) {
  212. const bool = hasReviews(currentDocument);
  213. if (bool) reviewDocuments.push(currentDocument);
  214. else return reviewDocuments;
  215. }
  216. promises = [];
  217. } catch (err) {
  218. console.error(err);
  219. return [];
  220. }
  221. }
  222. }
  223.  
  224. /**
  225. * Insert the reviews into the current reviews document.
  226. *
  227. * @param {HTMLElement[]} reviews
  228. */
  229. function insertReviews(reviews) {
  230. const reviewsTab = document.querySelector(REVIEW_TAB_SELECTOR);
  231. const moreReviewsButton = reviewsTab.querySelector(MORE_REVIEWS_BUTTON_SELECTOR).parentElement;
  232.  
  233. moreReviewsButton.remove();
  234. reviews.forEach(review => reviewsTab.appendChild(review));
  235. reviewsTab.appendChild(moreReviewsButton);
  236. }
  237.  
  238. function createLoadingScreen() {
  239. const loadingScreen = document.createElement("div");
  240. const css = "position: fixed;top: 0;width: 100vw;height: 100vh;background-color: rgba(0,0,0,0.9);color: white;place-items: center;display: grid;z-index: 999; font-size: 20px;"
  241. loadingScreen.id = LOADING_SCREEN_ID;
  242. loadingScreen.textContent = "Loading all reviews. Wait a moment...";
  243. loadingScreen.setAttribute("style", css)
  244. loadingScreen.style.display = "none";
  245. document.body.appendChild(loadingScreen);
  246. }
  247.  
  248. function toggleLoadingScreen() {
  249. const loadingScreen = document.getElementById(LOADING_SCREEN_ID);
  250. if (loadingScreen.style.display === "none") loadingScreen.style.display = "grid";
  251. else loadingScreen.style.display = "none";
  252. }
  253.  
  254. /**
  255. *
  256. * @param {Function} callback
  257. */
  258. function createSortingAndReviewsOptions(sortingCallback, preliminaryCallback) {
  259.  
  260. //Sort By Select element
  261. const mediaType = getMediaType();
  262. const sortBy = document.createElement("div");
  263. sortBy.style.float = "left";
  264.  
  265. switch (mediaType) {
  266. case "anime":
  267. sortBy.innerHTML = `<label for="${SELECT_ID}">Sort By</label> <select id="${SELECT_ID}" name="${SELECT_ID}"><option value="overall">overall</option><option value="story">story</option><option value="animation">animation</option><option value="sound">sound</option><option value="character">character</option><option value="enjoyment">enjoyment</option></select>`;
  268. break;
  269. case "manga":
  270. sortBy.innerHTML = `<label for="${SELECT_ID}">Sort By</label> <select id="${SELECT_ID}" name="${SELECT_ID}"><option value="overall">overall</option><option value="story">story</option><option value="art">art</option><option value="character">character</option><option value="enjoyment">enjoyment</option></select>`;
  271. }
  272.  
  273. sortBy.addEventListener("change", sortingCallback);
  274.  
  275. //Only Preliminary Reviews select
  276. const onlyPreliminaryReviews = document.createElement("div");
  277. onlyPreliminaryReviews.style.float = "left";
  278. onlyPreliminaryReviews.style.marginLeft = "20px"
  279.  
  280. onlyPreliminaryReviews.innerHTML = `<label for="${ONLY_PRELIMINARY_REVIEWS_ID}">Only Preliminary Reviews</label> <select id="${ONLY_PRELIMINARY_REVIEWS_ID}"><option value="No">No</option><option value="Yes">Yes</option></select>`;
  281. onlyPreliminaryReviews.addEventListener("change", preliminaryCallback);
  282.  
  283. //Sort Reviews button
  284. const sortButton = document.createElement("button");
  285. sortButton.style.marginLeft = "20px"
  286. sortButton.style.float = "left";
  287. sortButton.type = "button";
  288. sortButton.textContent = "Sort Reviews";
  289. sortButton.className = "inputButton btn-middle flat js-anime-update-button";
  290. sortButton.addEventListener("click", sortingCallback);
  291.  
  292. const optionsContainer = document.querySelector(OPTIONS_SELECTOR);
  293. optionsContainer.prepend(sortButton);
  294. if (preliminaryIsChecked()) optionsContainer.prepend(onlyPreliminaryReviews);
  295. optionsContainer.prepend(sortBy);
  296. }
  297.  
  298. function preliminaryIsChecked() {
  299. const checkbox = document.querySelector(PRELIMINARY_CHECKBOX_SELECTOR);
  300. return !checkbox ? false : checkbox.classList.contains("on");
  301. }
  302.  
  303. /**
  304. *
  305. * @param {Function} callback
  306. */
  307. function changeMoreReviewsButton(callback) {
  308. const moreReviewsButtons = [...document.querySelectorAll(MORE_REVIEWS_BUTTON_SELECTOR)];
  309. let moreReviewButton;
  310.  
  311. //we are on the first page
  312. if (moreReviewsButtons.length === 2) {
  313. const reviewsTab = document.querySelector(REVIEW_TAB_SELECTOR);
  314. moreReviewButton = moreReviewsButtons[1];
  315. const moreReviewButtonContainer = moreReviewButton.parentElement;
  316.  
  317. moreReviewsButtons[0].remove();
  318. moreReviewButton.parentElement.parentElement.remove();
  319. reviewsTab.appendChild(moreReviewButtonContainer);
  320. }
  321. //we are on page 2 and onwards
  322. else if (moreReviewsButtons.length === 4) {
  323. const moreReviewButtonParent1 = moreReviewsButtons[0].parentElement;
  324. const moreReviewButtonParent2 = moreReviewsButtons[3].parentElement;
  325.  
  326. moreReviewButton = moreReviewsButtons[3];
  327. moreReviewsButtons.forEach(button => button.remove());
  328. moreReviewButtonParent1.remove();
  329. moreReviewButtonParent2.innerHTML = "";
  330. moreReviewButtonParent2.appendChild(moreReviewButton);
  331. }
  332.  
  333. //this will only happen if MAL changes the layout
  334. else return;
  335.  
  336. moreReviewButton.href = "#!";
  337. moreReviewButton.addEventListener("click", callback);
  338. }
  339.  
  340. /**
  341. * A function that returns a callback function for Array.prototype.sort to sort the reviews. The arguments passed to this function determine the sorting order.
  342. * Example: sortBy("overall","character","sound") will return a function that sorts by the "overall" scores, if the overall scores are equal between reviewA and reviewB then compare their "character" scores, if they are also equal then check the "sound" scores. Otherwise, keep the same order.
  343. *
  344. * @param {string[]} category
  345. */
  346. function sortBy(...category) {
  347. return function(reviewA, reviewB) {
  348. const scoreA = getScoreTable(reviewA)[category[0]];
  349. const scoreB = getScoreTable(reviewB)[category[0]];
  350. if (scoreA < scoreB) return 1;
  351. if (scoreA > scoreB) return -1;
  352. if (category.length > 1) return sortBy(...category.slice(1))(reviewA, reviewB);
  353. return 0;
  354. }
  355. }
  356.  
  357. /**
  358. * Create the container that will contain the average scores.
  359. */
  360. function createAveragesContainer() {
  361. const separator = document.querySelector(SEPARATOR_SELECTOR);
  362. const scores = document.createElement("div");
  363. scores.id = AVERAGE_SCORES_ID;
  364. scores.style = "padding: 15px 0 15px 10px;";
  365. scores.textContent = "Calculating total average review score...";
  366. separator.insertAdjacentElement("afterend", scores);
  367. return scores;
  368. }
  369.  
  370. /**
  371. *
  372. * @param {HTMLElement[]} reviews
  373. * @param {boolean} onlyPreliminaries
  374. */
  375. function setAverages(reviews, onlyPreliminaries) {
  376. const averageScores = getAverages(reviews, onlyPreliminaries);
  377. const scoresContainer = document.getElementById(AVERAGE_SCORES_ID);
  378. scoresContainer.innerHTML = "";
  379.  
  380. const h2 = document.createElement("h2");
  381. h2.textContent = "Average Scores";
  382.  
  383. const table = document.createElement("table");
  384.  
  385. table.innerHTML = "<thead><tr><th>Category</th><th>Average</th></tr></thead><tbody></tbody>";
  386.  
  387. scoresContainer.appendChild(h2);
  388. scoresContainer.appendChild(table);
  389. const tbody = table.querySelector("tbody");
  390.  
  391. for (let property in averageScores) {
  392. const tr = document.createElement("tr");
  393. tr.innerHTML = `<td style="padding-right: 30px; padding-top: 5px;">${property}</td><td>${averageScores[property].toFixed(2)}</td>`;
  394. tbody.appendChild(tr);
  395. }
  396. }
  397.  
  398. /**
  399. *
  400. * @param {HTMLElement[]} reviews
  401. * @param {boolean} onlyPreliminaries
  402. * @return {AnimeScore | MangaScore}
  403. */
  404. function getAverages(reviews, onlyPreliminaries) {
  405. /** @type {Array<AnimeScore|MangaScore>} */
  406. const scores = [];
  407. const mediaType = getMediaType();
  408. let averageScores;
  409.  
  410. switch (mediaType) {
  411. case "anime":
  412. averageScores = {
  413. overall: 0,
  414. story: 0,
  415. animation: 0,
  416. sound: 0,
  417. character: 0,
  418. enjoyment: 0
  419. };
  420. break;
  421. case "manga":
  422. averageScores = {
  423. overall: 0,
  424. story: 0,
  425. art: 0,
  426. character: 0,
  427. enjoyment: 0
  428. };
  429. }
  430.  
  431. const nonZeroCounter = {
  432. ...averageScores
  433. };
  434. reviews.forEach(review => scores.push(getScoreTable(review)));
  435.  
  436. for (let score of scores) {
  437. if (onlyPreliminaries && score["reviewerHasCompleted"]) continue;
  438. for (let property in averageScores) {
  439. averageScores[property] += score[property];
  440. if (score[property] !== 0) nonZeroCounter[property]++;
  441. }
  442. }
  443.  
  444. for (let property in averageScores) averageScores[property] /= (nonZeroCounter[property] || 1);
  445.  
  446. return averageScores;
  447. }
  448.  
  449. /**
  450. *
  451. */
  452.  
  453. async function main() {
  454. function sortReviews() {
  455. const category = document.getElementById(SELECT_ID).value;
  456. const reviewsTab = document.querySelector(REVIEW_TAB_SELECTOR);
  457. const moreReviewsButton = reviewsTab.querySelector(MORE_REVIEWS_BUTTON_SELECTOR)
  458. moreReviewsButton && moreReviewsButton.parentElement.remove();
  459. currentPageReviews.sort(sortBy(category));
  460. currentPageReviews.forEach(review => reviewsTab.appendChild(review));
  461. moreReviewsButton && reviewsTab.appendChild(moreReviewsButton.parentElement);
  462. }
  463.  
  464. function moreReviews() {
  465. const lastIndex = Math.min(REVIEWS_PER_PAGE, allReviews.length);
  466. const nextReviews = allReviews.splice(0, lastIndex);
  467. insertReviews(nextReviews);
  468. currentPageReviews = [...currentPageReviews, ...nextReviews];
  469.  
  470. if (allReviews.length === 0) {
  471. const reviewsTab = document.querySelector(REVIEW_TAB_SELECTOR);
  472. reviewsTab.querySelector(MORE_REVIEWS_BUTTON_SELECTOR).parentElement.remove();
  473. }
  474. }
  475.  
  476. function showAverages() {
  477. setAverages([...allReviews, ...currentPageReviews], false);
  478. }
  479.  
  480. function preliminaryCallback() {
  481. const onlyPreliminaries = document.getElementById(ONLY_PRELIMINARY_REVIEWS_ID).value === "No" ? false : true;
  482. setAverages([...allReviews, ...currentPageReviews], onlyPreliminaries);
  483.  
  484. /**
  485. *
  486. * @param {HTMLElement} review
  487. */
  488. function hideReviewIfComplete(review) {
  489. if (onlyPreliminaries && getReviewerHasCompleted(review)) review.style.display = "none";
  490. else review.style.display = "block";
  491. }
  492.  
  493. allReviews.forEach(hideReviewIfComplete);
  494. currentPageReviews.forEach(hideReviewIfComplete);
  495. }
  496.  
  497. createLoadingScreen();
  498. toggleLoadingScreen();
  499. createAveragesContainer();
  500.  
  501. const allReviews = getReviews(await fetchAllReviewsPages());
  502. let currentPageReviews = getReviews(document);
  503.  
  504. createSortingAndReviewsOptions(sortReviews, preliminaryCallback);
  505. changeMoreReviewsButton(moreReviews);
  506. showAverages();
  507. toggleLoadingScreen();
  508. }
  509.  
  510. if (getCurrentPageNumber() === 1 && !hasReviews(document)) return;
  511. main();
  512. })();