動畫瘋資訊+

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

目前為 2022-08-23 提交的版本,檢視 最新版本

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

//---------------------External libarary---------------------//
/**
 *
 * detectIncognito v1.1.0 - (c) 2022 Joe Rutkowski <[email protected]> (https://github.com/Joe12387/detectIncognito)
 *
 **/
var detectIncognito=function(){return new Promise(function(t,o){var e,n="Unknown";function r(e){t({isPrivate:e,browserName:n})}function i(e){return e===eval.toString().length}function a(){(void 0!==navigator.maxTouchPoints?function(){try{window.indexedDB.open("test",1).onupgradeneeded=function(e){var t=e.target.result;try{t.createObjectStore("test",{autoIncrement:!0}).put(new Blob),r(!1)}catch(e){/BlobURLs are not yet supported/.test(e.message)?r(!0):r(!1)}}}catch(e){r(!1)}}:function(){var e=window.openDatabase,t=window.localStorage;try{e(null,null,null,null)}catch(e){return r(!0),0}try{t.setItem("test","1"),t.removeItem("test")}catch(e){return r(!0),0}r(!1)})()}function c(){navigator.webkitTemporaryStorage.queryUsageAndQuota(function(e,t){r(t<(void 0!==(t=window).performance&&void 0!==t.performance.memory&&void 0!==t.performance.memory.jsHeapSizeLimit?performance.memory.jsHeapSizeLimit:1073741824))},function(e){o(new Error("detectIncognito somehow failed to query storage quota: "+e.message))})}function d(){void 0!==Promise&&void 0!==Promise.allSettled?c():(0,window.webkitRequestFileSystem)(0,1,function(){r(!1)},function(){r(!0)})}void 0!==(e=navigator.vendor)&&0===e.indexOf("Apple")&&i(37)?(n="Safari",a()):void 0!==(e=navigator.vendor)&&0===e.indexOf("Google")&&i(33)?(e=navigator.userAgent,n=e.match(/Chrome/)?void 0!==navigator.brave?"Brave":e.match(/Edg/)?"Edge":e.match(/OPR/)?"Opera":"Chrome":"Chromium",d()):void 0!==document.documentElement&&void 0!==document.documentElement.style.MozAppearance&&i(37)?(n="Firefox",r(void 0===navigator.serviceWorker)):void 0!==navigator.msSaveBlob&&i(39)?(n="Internet Explorer",r(void 0===window.indexedDB)):o(new Error("detectIncognito cannot determine the browser"))})};
//---------------------External libarary---------------------//

let $ = jQuery
let dd = (...d) => {
  if(BAHAID !== 'nathan60107')return
  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('#', '')
}

function siteProcess(site) {
  if(!site) return ''
  return site.split('://')[1].replace('www.', '').split('/')[0]
}

function timeProcess(time) {
  if(!time || time === '不明') return ''
  let [, year, month, date] = time.match(/([0-9]{4})-([0-9]{2})-([0-9]{2})/)
  return `${year}-${parseInt(month)}~`
}

async function getBahaDate() {
  let bahaDbUrl = $('.data_intro .bluebtn')[1].href
  let bahaHtml = $((await GET(bahaDbUrl)).responseText)
  let nameJp = bahaHtml.find('.ACG-mster_box1 > h2')[0].innerText
  let nameEn = bahaHtml.find('.ACG-mster_box1 > h2')[1].innerText
  let site = decodeURIComponent(bahaHtml.find('.ACG-box1listB > li:contains("官方網站") > a')[0]?.href?.match(/url=(.+)/)[1] ?? '')
  let time = bahaHtml.find('.ACG-box1listA > li:contains("當地")')[0]?.innerText?.split(':')[1]

  return {
    nameJp: titleProcess(nameJp),
    nameEn: titleProcess(nameEn),
    site: siteProcess(site),
    fullUrl: site,
    time: timeProcess(time),
  }
}

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 = ''
  let match = ''
  switch (type) {
    case 'syoboi':
      site = 'https://cal.syoboi.jp/tid'
      match = 'https://cal.syoboi.jp/tid'
      break
    case 'allcinema':
      site = 'https://www.allcinema.net/cinema/'
      match = /https:\/\/www.allcinema.net\/cinema\/([0-9]{1,7})/
      break
  }

  let googleUrl = `https://www.google.com/search?as_q=${keyword}&as_qdr=all&as_sitesearch=${site}&as_occt=any`
  dd(`Google result: ${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.match(match))return link
  }
  return ''
}

async function searchSyoboi() {
  let site = bahaData.site
  let time = bahaData.time
  if(!site || !time) return ''
  if(['tv-tokyo.co.jp', 'tbs.co.jp', 'sunrise-inc.co.jp'].includes(site)){
    site = bahaData.fullUrl.match(/(tv-tokyo.co.jp\/anime\/[^\/]+)/)?.[1] ||
      bahaData.fullUrl.match(/(tv-tokyo.co.jp\/[^\/]+)/)?.[1] ||
      bahaData.fullUrl.match(/(tbs.co.jp\/anime\/[^\/]+)/)?.[1] ||
      bahaData.fullUrl.match(/(tbs.co.jp\/[^\/]+)/)?.[1] ||
      bahaData.fullUrl.match(/(sunrise-inc.co.jp\/[^\/]+)/)?.[1] || ''
  }
  let searchUrl = `https://cal.syoboi.jp/find?sd=0&kw=${site}&ch=&st=&cm=&r=0&rd=&v=0`
  dd(`Syoboi result: ${searchUrl}`)

  let syoboiHtml = (await GET(searchUrl)).responseText
  let syoboiResults = $($.parseHTML(syoboiHtml)).find('.tframe td')
  for (let result of syoboiResults) {
    let resultTime = $(result).find('.findComment')[0].innerText

    if(resultTime.includes(time)){
      let resultUrl = $(result).find('a').attr('href')
      return `https://cal.syoboi.jp${resultUrl}`
    }
  }
  return ''
}

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 ? bahaData.nameJp : bahaData.nameEn
  if (animeName === '') return null
  let allcinemaUrl = await google('allcinema', animeName)
  dd(`Allcinema url: ${allcinemaUrl}`)
  if(!allcinemaUrl) 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=>{
    return {
      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(searchGoogle = false) {
  changeState('syoboi')

  let nameJp = bahaData.nameJp
  if (nameJp === '') return null
  let syoboiUrl = await (searchGoogle ? google('syoboi', nameJp) : searchSyoboi())
  dd(`Syoboi url: ${syoboiUrl}`)
  if(!syoboiUrl) 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(song)

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

async function getCastHtml(json) {
  function replaceEach(array, getFrom = (it)=>it.from, getTo = (it)=>it.to) {
    array?.forEach((it) => {
      json.forEach((j, index) => {
        if (j.cv === getFrom(it) || j.cvName2 === getFrom(it)){
          json[index].cvName2 = getTo(it)
        }
      })
      nameList = nameList.replace(getFrom(it), getTo(it))
    })
  }
  let searchWikiUrl = (nameList) =>
    `https://ja.wikipedia.org/w/api.php?action=query&format=json&prop=langlinks|pageprops&titles=${nameList}&redirects=1&lllang=zh&llinlanguagecode=ja&lllimit=100&ppprop=disambiguation`


  let nameList = json.map(j => j.cv).join('|')
  let wikiApi = searchWikiUrl(nameList)
  let wikiJson = JSON.parse((await GET(wikiApi)).responseText)
  let disamb = _.filter(wikiJson.query.pages, ['pageprops', {disambiguation: ''}])
  let normalized = wikiJson.query.normalized
  let redirects = wikiJson.query.redirects
  dd(nameList, wikiJson, normalized, redirects, disamb)

  // Deal with wiki page normalized, redirects and disambiguation.
  replaceEach(normalized)
  replaceEach(redirects)
  if (disamb.length) {
    replaceEach(disamb, (it)=>it.title, (it)=>`${it.title} (声優)`)

    wikiApi = searchWikiUrl(nameList)
    wikiJson = JSON.parse((await GET(wikiApi)).responseText)
    redirects = wikiJson.query.redirects
    replaceEach(redirects)
  }
  // dd(wikiJson)

  return json.map(j => {
    // dd(j)
    let wikiPage = _.filter(wikiJson.query.pages, page =>
      page.title === j.cv || page.title === j.cvName2
    )[0]
    // dd(wikiPage)
    let zhName = wikiPage.langlinks?.[0]['*']
    let wikiUrl = zhName ? `https://zh.wikipedia.org/zh-tw/${zhName}` : `https://ja.wikipedia.org/wiki/${j.cvName2 ?? j.cv}`
    let wikiText = zhName ? 'Wiki' : 'WikiJP'

    return `
      <div>${j.char ?? ''}</div>
      <div>${j.cv}</div>
      ${wikiPage.missing === ''
        ? '<div></div>'
        : `<a href="${wikiUrl}" target="_blank">🔗${wikiText}</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 a {
      color: rgb(51, 145, 255)
    }
    #ani-info .bluebtn {
      font-size: 13px;
    }
    #ani-info .grid.cast {
      grid-template-columns: repeat(3, auto);
    }
    #ani-info .grid.song {
      grid-template-columns: repeat(3, auto);
    }
  `
}

async function changeState(state, params) {
  switch(state){
    case 'init':
      $('.anime-option').append(`
        <style type='text/css'>${getCss()}</style>
        <div id="ani-info">
          <ul class="data_type">
            <li>
              <span>aniInfo+</span>
              <i id="ani-info-msg">歡迎使用動畫瘋資訊+</i>
            </li>
          </ul>
        </div>
      `)
      break
    case 'btn':
      $('#ani-info-msg').html(`
        <div id="ani-info-main" class="bluebtn" onclick="aniInfoMain()">
          讀取動畫資訊
        </div>
      `)
      $('#ani-info-main')[0].addEventListener("click", main, {
        once: true
      });
      break
    case 'google':
      $('#ani-info-msg').html(`Google搜尋失敗,請點擊<a href="${params.url}" target="_blank">連結</a>解除reCAPTCHA後重整此網頁。`)
      break
    case 'syoboi':
      $('#ani-info-msg').html(`嘗試取得syoboi資料中...`)
      break
    case 'allcinema':
      $('#ani-info-msg').html(`嘗試取得allcinema資料中...`)
      break
    case 'fail':
      $('#ani-info-msg').html(`無法取得資料 ${params.error}`)
      break
    case 'result':
      let castHtml = await getCastHtml(params.cast)
      let songHtml = getSongHtml(params.song)
      $('#ani-info').html('')
      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>
      `)
      $('#ani-info').append(`
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            資料來源:<a href="${params.source}" target="_blank">${params.title}</a>
          </li>
        </ul>
      `)
      break
    case 'debug':
      let aaa = await getSyoboi()
      let bbb = await getSyoboi(true)
      let ccc = await getAllcinema()
      let ddd = await getAllcinema(false)
      $('#ani-info').html('')
      $('#ani-info').append(`
        <ul class="data_type">
          <li>
            <span>aniInfo+</span>
            <br>
            syoboi:<a href="${aaa?.source}" target="_blank">${aaa?.title}</a>
            <br>
            allcinema(jp):<a href="${ccc?.source}" target="_blank">${ccc?.title}</a>
            <br>
            allcinema(en):<a href="${ddd?.source}" target="_blank">${ddd?.title}</a>
            <br>
            syoboi(google):<a href="${bbb?.source}" target="_blank">${bbb?.title}</a>
            <br>
          </li>
        </ul>
      `)
      break
  }
}

async function main() {
  let debug = false
  try {
    if(debug){
      changeState('debug')
      return
    }
    let result = null
    result = await getSyoboi(false)
    if (!result) result = await getAllcinema(true)
    if (!result) result = await getAllcinema(false)
    if (!result) result = await getSyoboi(true)

    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})
    }
  }
}

(async function() {
  globalThis.bahaData = await getBahaDate()
  changeState('init')

  // Set user option default value.
  if(GM_getValue('auto') == undefined){ GM_setValue('auto', true); }

  // Set user option menu in Tampermonkey.
  let isAuto = GM_getValue('auto');
  GM_registerMenuCommand(`設定為${isAuto ? '手動' : '自動'}執行`, () => {
    GM_setValue('auto', !GM_getValue('auto'));
    location.reload();
  });

  // Do task or set button to wait for click and do task.
  if(isAuto) main()
  else changeState('btn')
})();

/**
 * 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)
 * [and its cdn](https://cdn.jsdelivr.net/gh/Joe12387/detectIncognito@main/detectIncognito.min.js)
 * [FF observe GM request](https://firefox-source-docs.mozilla.org/devtools-user/browser_toolbox/index.html)
 * [Wiki API](https://ja.wikipedia.org/wiki/%E7%89%B9%E5%88%A5:ApiSandbox#action=query&format=json&prop=langlinks%7Cpageprops&titles=%E4%B8%AD%E5%B3%B6%E7%94%B1%E8%B2%B4%20(%E5%A3%B0%E5%84%AA)&lllang=zh&llinlanguagecode=ja&lllimit=100&ppcontinue=&ppprop=disambiguation)
 * [Always use en/decodeURIComponent](https://stackoverflow.com/a/747845)
 */