動畫瘋資訊+

在動畫瘋中自動擷取動畫常見相關資訊,如CAST以及主題曲。

目前為 2022-07-31 提交的版本,檢視 最新版本

// ==UserScript==
// @name         動畫瘋資訊+
// @description  在動畫瘋中自動擷取動畫常見相關資訊,如CAST以及主題曲。
// @namespace    nathan60107
// @author       nathan60107(貝果)
// @version      0.2
// @license      MIT
// @homepage     https://home.gamer.com.tw/homeindex.php?owner=nathan60107
// @match        https://ani.gamer.com.tw/animeVideo.php?sn=*
// @icon         https://forum.gamer.com.tw/favicon.ico
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js
// @grant        GM_xmlhttpRequest
// @connect      google.com
// @connect      www.allcinema.net
// @connect      cal.syoboi.jp
// @connect      acg.gamer.com.tw
// @noframes
// ==/UserScript==

let $ = jQuery
let dd = (...d) => {
  d.forEach((it)=>{console.log(it)})
}

// async function isPrivateFF() {
//   return new Promise((resolve) => {
//     detectIncognito().then((result) => {
//       if(result.browserName === 'Firefox' && result.isPrivate) return resolve(true)
//       return resolve(false)
//     });
//   })
// }

function titleProcess(title){
  return title.replaceAll('-', '\\-').replaceAll('#', '')
}

async function getAnimeNameJp() {
  let bahaDbUrl = $('.data_intro .bluebtn')[1].href
  let animeNameJp = $((await GET(bahaDbUrl)).responseText).find('.ACG-mster_box1 > h2')[0].innerText
  return titleProcess(animeNameJp)
}

async function getAnimeNameEn() {
  let bahaDbUrl = $('.data_intro .bluebtn')[1].href
  let animeNameEn = $((await GET(bahaDbUrl)).responseText).find('.ACG-mster_box1 > h2')[1].innerText
  return titleProcess(animeNameEn)
}

async function GET(url) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest ({
      method:   "GET",
      url:      url,
      onload:   (response) => {
        resolve(response)
      },
      onerror:  (response)=>{reject(response)},
    });
  })
}

async function POST(url, payload, headers = {}) {
  let data = []
  Object.keys(payload).forEach(key => data.push(`${key}=${payload[key]}`))
  data = data.join('&')
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method:  "POST",
      url:     url,
      data:    data,
      headers: {
        // "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        'Content-Length': data.length,
        ...headers
      },
      onload:   (response)=>{
        resolve(response)
      },
      onerror:  (response)=>{
        reject(response)
      },
    })
  })
}

function getJson(str) {
  try{
    return JSON.parse(str)
  }catch{
    console.log('josn error')
    return {}
  }
}

async function google(type, keyword) {
  let site = ''
  switch (type) {
    case 'syoboi':
      site = 'https://cal.syoboi.jp/tid'
      break
    case 'allcinema':
      site = 'https://www.allcinema.net/cinema/'
      break
  }

  let googleUrl = `https://www.google.com/search?as_q=${keyword}&as_qdr=all&as_sitesearch=${site}&as_occt=any`
  dd(googleUrl)

  let googleHtml = (await GET(googleUrl)).responseText
  if (googleHtml.includes('為何顯示此頁')) throw { type: 'google', url: googleUrl }
  let googleResult = $($.parseHTML(googleHtml)).find('#res .v7W49e a')
  for(let goo of googleResult) {
    let link = goo.href.replace('http://', 'https://')
    if(link.startsWith(site))return link
  }
  return 'no result'
}

function songType(type) {
  type = type.toLowerCase().replace('section ', '')
  switch(type){
    case 'op':
      return 'OP'
    case 'ed':
      return 'ED'
    case 'st':
    case '挿入歌':
      return '插入曲'
    default:
      return '主題曲'
  }
}

async function getAllcinema(jpTitle = true) {
  changeState('allcinema')

  let animeName = jpTitle ? await getAnimeNameJp() : await getAnimeNameEn()
  if (animeName === '') return null
  let allcinemaUrl = await google('allcinema', animeName)
  dd(allcinemaUrl)
  if(allcinemaUrl === 'no result') return null

  let allcinemaId = allcinemaUrl.match(/https:\/\/www.allcinema.net\/cinema\/([0-9]{1,7})/)[1]
  let allcinemaHtml = (await GET(allcinemaUrl))
  let title = allcinemaHtml.responseText.match(/<title>([^<]*<\/title>)/)[1]

  let allcinemaXsrfToken = allcinemaHtml.responseHeaders.match(/XSRF-TOKEN=([^=]*); expires/)[1]
  let allcinemaSession = allcinemaHtml.responseHeaders.match(/allcinema_session=([^=]*); expires/)[1]
  let allcinemaCsrfToken = allcinemaHtml.responseText.match(/var csrf_token = '([^']+)';/)[1]
  let allcinemaHeader = {
    // ...(await isPrivateFF()
    //   ? { 'Cookie' : `XSRF-TOKEN=${allcinemaXsrfToken}; allcinema_session=${allcinemaSession}` }
    //   : {}
    // ),
    'X-CSRF-TOKEN': allcinemaCsrfToken,
    Accept: 'application/json, text/javascript, */*; q=0.01',
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
  }

  let castData = allcinemaHtml.responseText.match(/"cast":(.*)};/)[1]
  let castJson = getJson(castData)
  let cast = castJson.jobs[0].persons.map(it=>({
    char: it.castname,
    cv: it.person.personnamemain.personname
  }))
  let songData = await POST('https://www.allcinema.net/ajax/cinema', {
    ajax_data: 'moviesounds',
    key: allcinemaId,
    page_limit: 10
  }, allcinemaHeader)
  let songJson = getJson(songData.responseText)
  let song = songJson.moviesounds?.sounds.map(it=>({
    type: songType(it.sound.usetype),
    title: `「${it.sound.soundtitle}」`,
    singer: it.sound.credit.staff.jobs.
      filter(job=>job.job.jobname.includes('歌'))
      [0].persons[0].person.personnamemain.personname
  }))
  dd(castJson, songJson)

  return {
    source: allcinemaUrl,
    title, cast, song
  }
}

async function getSyoboi() {
  changeState('syoboi')

  let animeNameJp = await getAnimeNameJp()
  if (animeNameJp === '') return null
  let syoboiUrl = await google('syoboi', animeNameJp)
  dd(syoboiUrl)
  if(syoboiUrl === 'no result') return null
  let syoboiHtml = (await GET(syoboiUrl)).responseText
  let title = syoboiHtml.match(/<title>([^<]*<\/title>)/)[1]

  let castData = $($.parseHTML(syoboiHtml)).find('.cast table').html()
  let cast = castData.match(/<th[^<]*>([^<]*)<\/th><td><wbr><[^<]+>([^<]*)<\/a>/g)
    .map(it=>it.split('</th>'))
    .map(it=>({
      char: it[0].replaceAll(/<[^<]+>/g, ''),
      cv: it[1].replaceAll(/<[^<]+>/g, '')
    }))

  let song = []
  let songData = $($.parseHTML(syoboiHtml)).find('.op, .ed, .st')
  for(let sd of songData){
    song.push({
      type: songType(sd.className),
      title: $(sd).find('.title')[0].childNodes[2].data,
      singer: $(sd).find('th:contains("歌")').parent().children()[1].innerText,
    })
  }
  dd(castData, songData)

  return {
    source: syoboiUrl,
    title, cast, song
  }
}

function getCastHtml(json) {
  return json.map(j=>`
    <div>${j.char?j.char:''}</div>
    <div>${j.cv}</div>
    <a href="https://zh.m.wikipedia.org/zh-tw/${j.cv}" target="_blank">🔗Wiki</a>
  `).join('')
}

function getSongHtml(json) {
  return json.map(j=>`
    <div>${j.type}${j.title}</div>
    <div>${j.singer}</div>
    <a href="https://www.youtube.com/results?search_query=${j.title.slice(1, j.title.length-1)} ${j.singer}" target="_blank">
      🔎Youtube
    </a>
  `).join('')
}

function getCss() {
  return `
    #ani-info .grid {
      display: grid;
      gap: 10px;
      margin-top: 10px
    }
    #ani-info .grid.cast {
      grid-template-columns: repeat(3, auto);
    }
    #ani-info .grid.song {
      grid-template-columns: repeat(3, auto);
    }
  `
}

function changeState(state, params) {
  switch(state){
    case 'init':
      $('.anime-option').append(`
        <div id="ani-info">
        </div>
      `)
      break
    case 'google':
      $('#ani-info').html(`
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            Google搜尋失敗,請點擊<a href="${params.url}" target="_blank">連結</a>解除reCAPTCHA後重整此網頁。
          </li>
        </ul>
      `)
      break
    case 'syoboi':
        $('#ani-info').html(`
          <ul class="data_type">
            <li>
              <span>aniInfo+</span>
              嘗試取得syoboi資料中...
            </li>
          </ul>
        `)
        break
    case 'allcinema':
      $('#ani-info').html(`
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            嘗試取得allcinema資料中...
          </li>
        </ul>
      `)
      break
    case 'fail':
      $('#ani-info').html(`
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            無法取得資料 ${params.error}
          </li>
        </ul>
      `)
      break
    case 'result':
      dd(params)
      let castHtml = getCastHtml(params.cast)
      let songHtml = getSongHtml(params.song)
      $('#ani-info').html(`<style type='text/css'> ${getCss()} </style>
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            資料來源:<a href="${params.source}" target="_blank">${params.title}</a>
          </li>
        </ul>
      `)
      if(castHtml) $('#ani-info').append(`
        <ul class="data_type">
          <li>
            <span>CAST</span>
            <div class="grid cast">${castHtml}</div>
          </li>
        </ul>
      `)
      if(songHtml) $('#ani-info').append(`
        <ul class="data_type">
          <li>
            <span>主題曲</span>
            <div class="grid song">${songHtml}</div>
          </li>
        </ul>
      `)
      break
  }
}

(async function() {
  changeState('init')
  let result
  try {
    result = await getSyoboi()
    if (!result) result = await getAllcinema()
    if (!result) result = await getAllcinema(false)
    
    if (result) changeState('result', result)
    else changeState('fail', {error: ''})
  } catch(e) {
    if (e.type === 'google'){
      changeState('google', {url: e.url})
    } else {
      changeState('fail', {error: e})
    }
  }
})();

/**
 * Reference:
 * [Write userscript in VSC](https://stackoverflow.com/a/55568568)
 * [Same above but video](https://www.youtube.com/watch?v=7bWwkTWJy40)
 * [Detect browser private mode](https://stackoverflow.com/a/69678895/13069889)
 */