Greasy Fork

Greasy Fork is available in English.

Anisongs

Adds Anisongs to anime entries on AniList

当前为 2020-05-20 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name Anisongs
// @namespace Morimasa
// @author Morimasa
// @description Adds Anisongs to anime entries on AniList
// @match https://anilist.co/*
// @connect graphql.anilist.co
// @connect api.jikan.moe
// @connect www.reddit.com
// @version 1.10
// @license GPL-3.0-or-later
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @require https://cdnjs.cloudflare.com/ajax/libs/localforage/1.7.3/localforage.min.js
// ==/UserScript==
(() => {
const options = {
  cacheName: 'anison', // name in localstorage
  cacheTTL: 604800000, // 1 week in ms
  class: 'anisongs', // container class
}

localforage.config({
    name: 'Anisongs'
})

const Cache = {
  async add(key, value) {
    await localforage.setItem(key, value)
    return value
  },
  async get(key) {
    return localforage.getItem(key)
  }
}

GM_addStyle(`
  .${options.class} .anisong-entry {
    background: rgb(var(--color-foreground));
    border-radius: 3px;
    padding: 8px 10px;
    font-size: 1.3rem;
    margin-bottom: 10px;
  }
  .${options.class} .has-video {
    cursor: pointer;
    color: rgb(var(--color-text));
  }
  .${options.class} .has-video:hover {
	  transition: .15s;
    color: rgb(var(--color-blue));
  }
  .${options.class} .anisong-entry video {
    cursor: auto;
    margin-top: 10px;
    width: 39em;
  }
`)

const temp = {
  last: null,
  target: null
}

const API = {
  async getMedia(id) {
    const query = "query($id:Int){Media(id:$id){idMal startDate{year} status}}",
          vars = {id}
    const resp = await request("https://graphql.anilist.co", {
      method: "POST",
      body: JSON.stringify({query: query, variables: vars}),
    })
    try {
      const {idMal, startDate, status} = resp.data.Media
      return {mal_id: idMal, year: startDate.year, status}
    }
    catch(e) {
      console.log("Anisongs: Error getting malId")
      return null
    }
  },
  async getSongs(mal_id) {
    const splitSongs = list => list.flatMap(e => e.split(/\#\d{1,2}\s/)).filter(e => e!=="")
    let {opening_themes, ending_themes} = await request(`https://api.jikan.moe/v3/anime/${mal_id}/`)
    opening_themes = splitSongs(opening_themes)
    ending_themes = splitSongs(ending_themes)
    return {opening_themes, ending_themes}
  }
}

function request(url, options={}) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      url,
      method: options.method || "GET",
      headers: options.headers || {Accept: "application/json",
                                   "Content-Type": "application/json"},
      responseType: options.responseType || "json",
      data: options.body || options.data,
      onload: res => resolve(res.response),
      onerror: reject
    })
  })
}

class VideoElement {
  constructor(parent, url) {
    this.url = url
    this.parent = parent
    this.make()
  }

  toggle() {
    if (this.el.parentNode) {
      this.el.remove()
    }
    else {
      this.parent.append(this.el)
      this.el.children[0].autoplay = true // autoplay
    }
  }

  make() {
    const box = document.createElement('div'),
          vid = document.createElement('video')
    vid.src = this.url
    vid.controls = true
    vid.preload = "none"
    vid.volume = 0.4
    box.append(vid)
    this.el = box
  }
}

function insert(songs, parent) {
  if (!songs || !songs.length) {
    const node = document.createElement('div')
    node.innerText = 'No songs to show (つ﹏<)・゚。'
    node.style.textAlign = "center"
    parent.appendChild(node)
  }
  else {
    songs.forEach( (song, i) => {
      const node = document.createElement('div')
      const txt = `${i+1}. ${song.title || song}`
      if (song.url) {
        const vid = new VideoElement(node, song.url)
        node.addEventListener("click", () => vid.toggle())
        node.classList.add("has-video")
      }
      node.innerText = txt
      node.classList.add("anisong-entry")
      parent.appendChild(node)
    })
  }
}

function createTargetDiv(text, target, pos) {
  let el = document.createElement('div');
  el.appendChild(document.createElement('h2'));
  el.children[0].innerText = text;
  el.classList = options.class;
  target.insertBefore(el, target.children[pos]);
  return el;
}

function placeData(data) {
  cleaner(temp.target);
  let op = createTargetDiv('Openings', temp.target, 0);
  let ed = createTargetDiv('Endings', temp.target, 1);
  insert(data.opening_themes, op);
  insert(data.ending_themes, ed);
}

function cleaner(target) {
  if (!target) return;
  let el = target.querySelectorAll(`.${options.class}`);
  el.forEach(e => target.removeChild(e))
}

function TTLpassedCheck(timestamp, TTL) {
  return (timestamp + TTL) < +new Date()
}

async function launch(currentid) {
  // get from cache and check TTL
  const cache = await Cache.get(currentid) || {time: 0};
  const TTLpassed = TTLpassedCheck(cache.time, options.cacheTTL);
  if (TTLpassed) {
    const {mal_id, year, status} = await API.getMedia(currentid);
    if (mal_id) {
      let {opening_themes, ending_themes} = await API.getSongs(mal_id);
      // add songs to cache if they're not empty and query videos
      if (opening_themes.length || ending_themes.length) {
        if (["FINISHED", "RELEASING"].includes(status)) {
          try {
            const _videos = await new Videos(year, mal_id).get()
            opening_themes = Videos.merge(opening_themes, _videos.OP)
            ending_themes = Videos.merge(ending_themes, _videos.ED)
          }
          catch(e){console.log("Anisongs", e)} // 🐟
        }
        await Cache.add(currentid, {opening_themes, ending_themes, time: +new Date()});
      }
      // place the data onto site
      placeData({opening_themes, ending_themes});
      return "Downloaded songs"
    }
    else {
      return "No malid"
    }
  }
  else {
    // place the data onto site
    placeData(cache);
    return "Used cache"
  }
}

class Videos {
  constructor(year, id_mal) {
    this.year = this.parseYear(year)
    this.URL = `https://www.reddit.com/r/AnimeThemes/wiki/${this.year}.json`;
    this.id_mal = id_mal
  }

  async get() {
    const cache_name = `cache${this.year}`
    const cached = await Cache.get(cache_name)
    const makepromise = async v => v // small hack because things
    if (cached && !TTLpassedCheck(cached.time, options.cacheTTL)) {
      console.log("Anisongs: used videos cache")
      return makepromise(cached.html)
        .then(cache => Videos.find(cache, this.id_mal))
        .then(Videos.groupTypes)
    }
    else {
      return request(this.URL)
        .then(Videos.parseResponse)
        .then(html => Cache.add(cache_name, {html, time: +new Date()}))
        .then(cache => cache.html)
        .then(html => Videos.find(html, this.id_mal))
        .then(Videos.groupTypes)
    }
  }

  parseYear(year) {
    if (year > 1999) {
      return year
    }
    else {
      return `${year.toString()[2]}0s`
    }
  }

  static parseResponse(data) {
    const html = data.data.content_html
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, "\"")
    return html
  }

  static find(html_str, mal_id) {
    const html = new DOMParser().parseFromString(html_str, "text/html")
    const findTable = el => el.nodeName === "TABLE" ? el : findTable(el.nextElementSibling),
          url_el = html.querySelector(`a[href='https://myanimelist.net/anime/${mal_id}/']`),
          table = findTable(url_el.parentNode.nextElementSibling),
          entries = [...table.children[1].children]
    return entries.map(Videos.parseSong).filter(e => e)
  }

  static parseSong(entry) {
    const cells = [...entry.cells]
    if (cells[0].innerText === "") return null
    const url = cells[1].children.length ? cells[1].children[0].href : null
    let [_, type, n] = cells[0].innerText.match(/(OP|ED)(\d*)/)
    n = n!=="" ? parseInt(n) : 1
    return {type, n, url}
  }

  static groupTypes(songs) {
    const groupBy = (xs, key) => {
      return xs.reduce(function(rv, x) {
        (rv[x[key]] = rv[x[key]] || []).push(x);
        return rv;
      }, {});
    };
    return groupBy(songs, "type")
  }

  static merge(entries, videos) {
    const findUrl = n => {
      const found = videos.find(e => e.n == n+1)
      return found ? found.url : null
    }
    return entries.map((e, i) => {
      return {
        title: e,
        url: findUrl(i)
      }
    })
  }
}

let observer = new MutationObserver(() => {
    let currentpath = window.location.pathname.split("/");
    if (currentpath[1] === 'anime') {
      let currentid = currentpath[2];
      let location = currentpath.pop();
      if (location!=='') temp.last=0;
      temp.target = document.querySelectorAll('.grid-section-wrap')[2];
      if(temp.last!==currentid && location==='' && temp.target) {
        temp.last = currentid;
        launch(currentid).then(e => console.log(`Anisongs: ${e}`));
      }
     }
   else if (currentpath[1] === 'manga'){
    cleaner(temp.target);
    temp.last = 0;
   }
   else {
     temp.last = 0;
   }
});
observer.observe(document.getElementById('app'), {childList: true, subtree: true});
})()