MyAnimeList (MAL) Alternative History

Displays anime/manga series history based on RSS feed, instead of default episode/chapter history

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        MyAnimeList (MAL) Alternative History
// @namespace   https://greasyfork.org/users/7517
// @description Displays anime/manga series history based on RSS feed, instead of default episode/chapter history
// @icon        http://i.imgur.com/b7Fw8oH.png
// @version     4.2.1
// @author      akarin
// @include     /^https?:\/\/myanimelist\.net\/history\//
// @run-at      document-start
// @grant       none
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  class Cache {
    constructor (name, username) {
      this.version = '4.0';
      this.name = name;
      this.username = username;
      this.time = 1000 * 60 * 60 * 36;
      this.fetchDelay = 800;
    }

    encodeKey (key) {
      return this.name + '#' + this.version + '#' + key;
    }

    loadValue (key, value) {
      try {
        const result = JSON.parse(localStorage.getItem(this.encodeKey(key)));
        return result == null ? value : result;
      } catch (e) {
        console.error(e.name + ': ' + e.message);
        return value;
      }
    }

    saveValue (key, value) {
      localStorage.setItem(this.encodeKey(key), JSON.stringify(value));
    }
  }

  class Content {
    constructor ($, cache) {
      this.$ = $;
      this.cache = cache;
      this.date = Date.now();
      this.body = this.$('<div id="ah_history_content">Loading history...</div>');
      this.entries = [];

      this.feedsCount = 0;
      this.feedsTotal = document.URL.match(/^https?:\/\/myanimelist\.net\/history\/(?!.*\/.)/) ? 2 : 1;
    }

    setItem (id, type, item) {
      if (!item || typeof item !== 'object' || item.constructor !== Object) {
        return false;
      }

      if (!item.hasOwnProperty('info') || item.info.length === 0 ||
          !item.hasOwnProperty('cover') || item.cover.length === 0) {
        return false;
      }

      let el = document.querySelector('div#info_' + type + '_' + id);
      if (!el) {
        return false;
      }
      el.innerHTML = item.info;

      el = document.querySelector('img#cover_' + type + '_' + id);
      if (!el) {
        return false;
      }
      el.setAttribute('src', item.cover);

      return true;
    }

    loadItem (id, type) {
      const itemData = this.cache.loadValue('item.data_' + type + '_' + id, {});
      const itemTime = this.cache.loadValue('item.time_' + type + '_' + id, 0);
      return this.setItem(id, type, itemData) ? (this.date <= parseInt(itemTime)) : false;
    }

    saveItem (id, type, data) {
      const item = { info: '', cover: '' };

      const info = {
        first: this.$('span.dark_text:contains(' + (type === 'anime' ? 'Studios' : 'Authors') + ':)', data).parent(),
        genres: this.$('span.dark_text:contains(Genres:)', data).parent(),
        score: this.$('span.dark_text:contains(Score:)', data).parent(),
        rank: this.$('span.dark_text:contains(Ranked:)', data).parent(),
        popularity: this.$('span.dark_text:contains(Popularity:)', data).parent()
      };

      this.$('meta, sup, .statistics-info', info.score).remove();
      this.$('sup, .statistics-info', info.rank).remove();

      item.info = info.first.html() + '<br>' + info.genres.html() + '<br>' +
                  info.score.html() + info.rank.html() + info.popularity.html();

      const cover = this.$('img[itemprop="image"]', data);
      if (cover.length > 0 && cover[0].hasAttribute('data-src')) {
        item.cover = cover.attr('data-src').replace(/\.(\w+)$/, 't.$1');
      } else if (cover.length > 0 && cover[0].hasAttribute('src')) {
        item.cover = cover.attr('src').replace(/\.(\w+)$/, 't.$1');
      } else {
        item.cover = 'https://myanimelist.cdn-dena.com/images/qm_50.gif';
      }

      this.cache.saveValue('item.data_' + type + '_' + id, item);
      this.cache.saveValue('item.time_' + type + '_' + id, this.date + this.cache.time);
      return this.setItem(id, type, item);
    }

    async loadFeeds () {
      const self = this;
      const $ = this.$;

      const request = async (url, feed) => {
        const response = await fetch(url);
        if (!response.ok) {
          return;
        }

        $('item', this.$.parseXML(await response.text())).each(function () {
          self.entries.push({
            gtype: feed,
            title: $('title', this).text().match(/^(.+)(\s-(?!.*\s-))/)[1],
            type: $('title', this).text().match(/(\s-(?!.*\s-))\s?(.*)$/)[2].replace(/^$/, '-'),
            link: $('link', this).text().replace(/^https?:\/\/[^/]+\//, '/'),
            id: $('link', this).text().match(/\/(\d+)\//)[1],
            status: $('description', this).text().match(/^(.+)\s-\s/)[1]
              .replace(/(Watch)/, feed === 'manga' ? 'Read' : '$1'),
            progress: $('description', this).text().match(/\s-\s(.+)$/)[1]
              .replace(/\s(chapters|episodes)/, '')
              .replace(/\sof\s/, '/')
              .replace(/^0\//, '-/')
              .replace(/\?/, '-'),
            date: new Date($('pubDate', this).text())
          });
        });

        this.feedsCount += 1;
      };

      if (this.feedsTotal > 1 || document.URL.match(/^https?:\/\/myanimelist\.net\/history\/[^/]+\/anime/)) {
        await request('/rss.php?type=rw&u=' + this.cache.username, 'anime');
      }

      if (this.feedsTotal > 1 || document.URL.match(/^https?:\/\/myanimelist\.net\/history\/[^/]+\/manga/)) {
        await request('/rss.php?type=rm&u=' + this.cache.username, 'manga');
      }

      const table = $('<table id="ah_history_table" width="100%" border="0" cellpadding="0" cellspacing="0">')
        .html('<tr>' +
              '<td class="normal_header" width="50">Image</td>' +
              '<td class="normal_header">Title</td>' +
              '<td class="normal_header" width="70">Type</td>' +
              '<td class="normal_header" width="90">Status</td>' +
              '<td class="normal_header" width="70">Progress</td>' +
              '<td class="normal_header" width="125">Date</td>' +
              '</tr>')
        .appendTo(this.body.empty());

      const queue = [];

      this.entries.sort((a, b) => b.date - a.date);
      this.entries.forEach((entry, i) => {
        const dateLast = (i < this.entries.length - 1) && (entry.date.getDate() !== this.entries[i + 1].date.getDate());
        const date = entry.date.toString()
          .replace(/^\w+\s/, '')
          .replace(/(\d+)/, '$1,')
          .replace(/(\d{4})/, '$1,')
          .replace(/(\d\d:\d\d).+$/, '$1');

        this.$('<tr' + (dateLast ? ' class="date_last"' : '') + '>')
          .html('<td valign="top"><div class="picSurround"><a href="' + entry.link + '">' +
                '<img id="cover_' + entry.gtype + '_' + entry.id + '" src="/images/spacer.gif" /></a></div></td>' +
                '<td valign="top"><a href="' + entry.link + '"><strong>' + entry.title + '</strong></a>' +
                '<div id="info_' + entry.gtype + '_' + entry.id + '"></div></td>' +
                '<td>' + entry.type + '</td>' +
                '<td>' + entry.status + '</td>' +
                '<td>' + entry.progress + '</td>' +
                '<td>' + date + '</td>'
          ).appendTo(table);

        if (!this.loadItem(entry.id, entry.gtype)) {
          queue.push(async () => {
            const response = await fetch('/' + entry.gtype + '/' + entry.id + '/_/pics');
            if (response.ok) {
              this.saveItem(entry.id, entry.gtype, await response.text());
            }
          });
        }
      });

      queue.forEach((request, i) => {
        setTimeout(request, i * this.cache.fetchDelay);
      });
    }
  }

  class MAL {
    constructor ($, name) {
      const username = document.URL.match(/^https?:\/\/myanimelist\.net\/history\/([^/]+)/)[1];
      this.cache = new Cache(name, username);
      this.content = new Content($, this.cache);
    }

    main () {
      const history = document.querySelector('#horiznav_nav ~ div:last-of-type');
      history.style.display = 'none';
      history.parentNode.insertBefore(this.content.body[0], history);

      if (history.querySelector('td')) {
        const toggler = document.createElement('a');
        toggler.setAttribute('href', 'javascript:void(0);');
        toggler.appendChild(document.createTextNode('Show Detailed History'));
        toggler.style.display = 'block';
        toggler.style.width = '100%';
        toggler.style.textAlign = 'center';

        toggler.onclick = () => {
          if (history.style.display !== 'none') {
            toggler.textContent = 'Show Detailed History';
            history.style.display = 'none';
          } else {
            toggler.textContent = 'Hide Detailed History';
            history.style.display = '';
          }
        };

        history.parentNode.insertBefore(toggler, history);
      } else {
        history.remove();
      }

      this.content.loadFeeds();

      const style = document.createElement('style');
      style.setAttribute('type', 'text/css');
      style.appendChild(document.createTextNode(
        '#ah_history_content { padding: 0 15px 10px 15px; }' +
        '#ah_history_table { min-width: 100%; }' +
        '#ah_history_table td { padding: 4px; border-bottom: 1px solid #ebebeb; text-align: center; }' +
        '#ah_history_table tr.date_last td { border-bottom: 2px solid #dadada; }' +
        '#ah_history_table td.normal_header { border-bottom: 1px solid #bebebe; }' +
        '#ah_history_table td:nth-of-type(2) { text-align: left; }' +
        '#ah_history_table td div[id^="info_"] { padding: 4px 0 2px; font-size: 10px; color: #666; }'
      ));
      document.querySelector('head').appendChild(style);
    }
  }

  const style = document.createElement('style');
  style.setAttribute('type', 'text/css');
  style.appendChild(document.createTextNode('.page-common #content { display: none !important; }'));

  const head = document.querySelector('head');
  if (head) {
    head.appendChild(style);
  }

  const callback = () => {
    try {
      (new MAL(jQuery, 'alternative_history')).main();
    } catch (e) {
      console.error(e.name + ': ' + e.message);
    } finally {
      style.remove();
    }
  };

  if (document.readyState !== 'loading') {
    callback();
  } else {
    document.addEventListener('DOMContentLoaded', callback);
  }
}());