Greasy Fork

Anisongs

Adds Anisongs to anime entries on AniList

目前为 2020-05-20 提交的版本。查看 最新版本

// ==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});
})()