ニコニコ動画 引用コメントツール

dアニメストア ニコニコ支店の引用コメント関連のツール

当前为 2022-09-10 提交的版本,查看 最新版本

// ==UserScript==
// @name         ニコニコ動画 引用コメントツール
// @namespace    https://midra.me
// @version      1.5.1
// @description  dアニメストア ニコニコ支店の引用コメント関連のツール
// @author       Midra
// @license      MIT
// @match        https://www.nicovideo.jp/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=nicovideo.jp
// @run-at       document-start
// @noframes
// @grant        unsafeWindow
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      nicovideo.jp
// @require      https://gf.qytechs.cn/scripts/7212-gm-config-eight-s-version/code/GM_config%20(eight's%20version).js?version=156587
// ==/UserScript==

(() => {
  'use strict'

  //----------------------------------------
  // 設定初期化
  //----------------------------------------
  const configInitData = {
    extraMain: {
      label: '「引用コメント」を「チャンネルコメント」に統合する',
      type: 'checkbox',
      default: true,
    },
    extraEasy: {
      label: '「引用かんたんコメント」を「かんたんコメント」に統合する',
      type: 'checkbox',
      default: true,
    },
    extraMainFromDanime: {
      label: '「dアニメストア ニコニコ支店」のコメントを引用・統合する',
      type: 'checkbox',
      default: false,
    },
    forcedExtra: {
      label: 'コメントを強制的に引用・統合する',
      type: 'checkbox',
      default: false,
    },
    showAddedCommentCount: {
      label: '統合したコメントの数をコメント数横に表示する',
      type: 'checkbox',
      default: true,
    },
    showExtraViewCount: {
      label: '引用した動画の再生数を再生数横に表示する',
      type: 'checkbox',
      default: false,
    },
    deleteExtra: {
      label: '「引用コメント」と「引用かんたんコメント」を非表示にする',
      type: 'checkbox',
      default: false,
    },
    deleteEasy: {
      label: '「かんたんコメント」と「引用かんたんコメント」を非表示にする',
      type: 'checkbox',
      default: false,
    },
  }
  GM_config.init('ニコニコ動画 引用コメントツール 設定', configInitData)

  GM_config.onload = () => {
    setTimeout(() => {
      alert('設定を反映させるにはページを再読み込みしてください。')
    }, 200)
  }

  GM_registerMenuCommand('設定', GM_config.open)

  // 設定取得
  const config = {}
  Object.keys(configInitData).forEach(v => { config[v] = GM_config.get(v) })
  console.log('[ECT] config:', config)

  if (!location.pathname.startsWith('/watch/')) return

  /** @type {(input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>} */
  const fetch = unsafeWindow.fetch

  //----------------------------------------
  // ECTオブジェクト
  //----------------------------------------
  const ECT = {
    DANIME_CHANNEL_ID: 'ch2632720',

    /** 現在のページの動画ID */
    get videoId() { return location.pathname.split('/')[2] },

    api: {
      /** 動画情報 API (ログイン必須) */
      WATCH_V3: 'https://www.nicovideo.jp/api/watch/v3',
      /** コメント取得 API */
      THREADS: 'https://nvcomment.nicovideo.jp/v1/threads',
      /** 動画に紐付いたdアニメストア ニコニコ支店の動画のIDを取得するAPI */
      CHANNEL_VIDEO_DANIME_LINKS: 'https://public-api.ch.nicovideo.jp/v1/user/channelVideoDAnimeLinks',
      /** スナップショット検索 API */
      SEARCH_V2: 'https://api.search.nicovideo.jp/api/v2/snapshot/video/contents/search',

      /****************************************
       * 動画の情報を取得
       * @param {string} videoId 動画のID
       * @returns {Promise<{} | undefined>} 動画の情報
       */
      async getVideoData(videoId) {
        if (videoId === void 0) {
          throw new Error('[ECT] ERROR: videoId is undefined')
        }
        try {
          const requestQuery = {
            actionTrackId: `${Array.from(Array(10)).map(() => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[Math.random() * 62 | 0]).join('')}_${Date.now()}`,
          }
          const res = await fetch(`${this.WATCH_V3}/${videoId}?${new URLSearchParams(requestQuery)}`, {
            method: 'GET',
            headers: {
              'x-client-os-type': 'others',
              'x-frontend-id': 6,
              'x-frontend-version': 0,
            },
          })
          const json = await res.json()
          if (json.data !== void 0) {
            return json.data
          }
          else {
            throw new Error({
              message: '[ECT] ERROR: getVideoData',
              object: res,
            })
          }
        } catch (e) {
          throw new Error(e)
        }
      },

      /****************************************
       * 動画のコメントを取得
       * @param {{}} videoData 動画の情報
       * @returns {Promise<{} | undefined>} 動画のコメント
       */
      async getThreads(videoData) {
        if (videoData === void 0) {
          throw new Error('[ECT] ERROR: videoData is undefined')
        }
        try {
          const res = await fetch(this.THREADS, {
            method: 'POST',
            headers: {
              'x-client-os-type': 'others',
              'x-frontend-id': 6,
              'x-frontend-version': 0,
            },
            body: JSON.stringify({
              additionals: {},
              params: videoData.comment?.nvComment?.params || {},
              threadKey: videoData.comment?.nvComment?.threadKey,
            }),
          })
          const json = await res.json()
          if (json.data !== void 0) {
            return json.data.threads
          }
          else {
            throw new Error({
              message: '[ECT] ERROR: getThreads',
              object: res,
            })
          }
        } catch (e) {
          throw new Error(e)
        }
      },

      /****************************************
       * 関連付けられた動画のIDを取得
       * @param {string} videoId 動画のID
       * @returns {Promise<string | undefined>} 関連付けられた動画のID
       */
      async getLinkedVideoId(videoId) {
        if (videoId === void 0) {
          throw new Error('[ECT] ERROR: videoId is undefined')
        }
        try {
          const requestQuery = {
            videoId: videoId,
            _frontendId: 6,
          }
          const res = await fetch(`${this.CHANNEL_VIDEO_DANIME_LINKS}?${new URLSearchParams(requestQuery)}`)
          const json = await res.json()
          if (json.data !== void 0) {
            return json.data.items[0].linkedVideoId
          }
          else {
            throw new Error({
              message: '[ECT] ERROR: getLinkedVideoId',
              object: res,
            })
          }
        } catch (e) {
          throw new Error(e)
        }
      },

      /****************************************
       * 動画を検索
       * @param {{
       *   q: string;
       *   targets: string[];
       *   fields?: string[];
       *   filters?: {};
       *   _sort?: string;
       *   _offset?: number;
       *   _limit?: number;
       * }} query クエリパラメータ
       */
      async search(query) {
        return new Promise((resolve, reject) => {
          if (query === void 0) {
            reject('query is undefined')
          }
          try {
            const requestQuery = {
              q: query.q,
              targets: query.targets.join(),
              fields: query.fields?.join() || ['contentId', 'title', 'channelId', 'lengthSeconds', 'tags'].join(),
              _sort: query._sort || '+startTime',
              _offset: query._offset,
              _limit: query._limit || 5,
              _context: 'ect',
            }
            if (query.filters !== void 0) {
              Object.entries(query.filters).forEach(val => {
                if (Array.isArray(val[1]) && val[1].length === 2) {
                  requestQuery[`filters[${val[0]}][gte]`] = val[1][0]
                  requestQuery[`filters[${val[0]}][lte]`] = val[1][1]
                }
                else {
                  requestQuery[`filters[${val[0]}][0]`] = val[1]
                }
              })
            }
            ECT.util.filterObject(requestQuery)
            GM_xmlhttpRequest({
              method: 'GET',
              url: `${this.SEARCH_V2}?${new URLSearchParams(requestQuery)}`,
              headers: {
                'User-Agent': 'ECT/1.0',
              },
              responseType: 'json',
              onload: e => {
                if (e.response !== void 0) {
                  resolve(e.response.data)
                }
                else {
                  reject({
                    message: '[ECT] ERROR: search',
                    object: res,
                  })
                }
              },
              onerror: e => reject(e),
            })
          } catch (e) {
            reject(e)
          }
        })
      },
    },

    /****************************************
     * 動画情報からチャンネルコメントの情報を取得
     * @param {{}} videoData 動画情報
     * @returns {[]} チャンネルコメントの情報
     */
    getChannnelThreadsData(videoData) {
      return videoData?.comment?.threads?.filter(thread => {
        return {
          'main': 'community',
          'easy': 'easy',
        }[thread.forkLabel] === thread.label
      }) || []
    },

    /****************************************
     * 動画情報から引用コメントの情報を取得
     * @param {{}} videoData 動画情報
     * @returns {[]} 引用コメントの情報
     */
    getExtraThreadsData(videoData) {
      return videoData?.comment?.threads?.filter(thread => {
        return {
          'main': 'extra-community',
          'easy': 'extra-easy',
        }[thread.forkLabel] === thread.label
      }) || []
    },

    /****************************************
     * 関連付けられた動画を取得
     * @param {string | undefined} videoId 動画ID
     * @returns {Promise<{ videoData: {};  threads: {};} | undefined>}
     */
    async getLinkedVideo(videoId) {
      try {
        const linkedVideoId = await ECT.api.getLinkedVideoId(videoId)
        const linkedVideoData = await ECT.api.getVideoData(linkedVideoId)
        const linkedVideoThreads = await ECT.api.getThreads(linkedVideoData)

        return {
          videoData: linkedVideoData,
          threads: linkedVideoThreads,
        }
      } catch (e) {
        console.error(e)
      }
    },

    /****************************************
     * 動画情報から一致する動画を取得
     * @param {{}} videoData 動画情報
     * @returns {Promise<{ videoData: {};  threads: {};} | undefined>}
     */
    async getIdenticalVideo(videoData) {
      if (
        videoData === void 0 ||
        videoData.video === void 0
      ) {
        throw new Error('[ECT] ERROR: videoData is undefined')
      }
      try {
        const title = this.util.normalizeTitle(videoData.video.title)
        console.log('[ECT] search title:', title)
        const duration = videoData.video.duration
        const query = {
          targets: ['title'],
          filters: {
            'categoryTags': 'アニメ',
            'genre.keyword': 'アニメ',
            'lengthSeconds': Number.isFinite(duration) ? [duration - 2, duration + 2] : void 0,
          },
        }
        // 検索 1回目 (共通化させたタイトルを使う)
        let searchResult = await this.api.search({
          q: title,
          ...query,
        })
        console.log('[ECT] search result 1:', searchResult)
        // 検索 2回目 (タイトルをそのまま使う)
        if (Array.isArray(searchResult) && searchResult.length === 0) {
          searchResult = await this.api.search({
            q: videoData.video.title,
            ...query,
          })
          console.log('[ECT] search result 2:', searchResult)
        }
        // 検索 3回目 (スナップショットAPIを使わない)
        if (
          searchResult === void 0 ||
          Array.isArray(searchResult) && searchResult.length <= 1
        ) {
          const res = await fetch(`https://www.nicovideo.jp/search/${encodeURIComponent(title)}?genre=anime&sort=f&order=a`)
          const elem = document.createElement('html')
          elem.insertAdjacentHTML('beforeend', await res.text())
          const items = elem.querySelectorAll('.item[data-video-item][data-video-id^="so"]')
          searchResult = Array.from(items).filter(item => {
            const videoLength = item.getElementsByClassName('videoLength')[0].textContent.trim().split(':')
            const videoDuration = videoLength.length === 2 ? Number(videoLength[0]) * 60 + Number(videoLength[1]) : 0
            return Math.abs(videoDuration - duration) <= 2
          }).map(item => ({
            contentId: item.dataset.videoId,
            title: item.querySelector('.itemTitle > a')?.title?.trim(),
          }))
          console.log('[ECT] search result 3:', searchResult)
        }

        if (Array.isArray(searchResult) && 0 < searchResult.length) {
          const filtered = searchResult.filter(val => (
            this.util.normalizeTitle(val.title) === title &&
            val.contentId !== videoData.video.id
          ))
          console.log('[ECT] search result (filtered):', filtered)
          if (filtered[0] !== void 0) {
            const identicalVideoData = await ECT.api.getVideoData(filtered[0].contentId)
            if (identicalVideoData?.channel?.isOfficialAnime) {
              const identicalVideoThreads = await ECT.api.getThreads(identicalVideoData)
              return {
                videoData: identicalVideoData,
                threads: identicalVideoThreads,
              }
            }
          }
        }
      } catch (e) {
        console.error(e)
      }
    },

    util: {
      /****************************************
       * オブジェクトのプロパティからnullとundefinedを除去
       * @param {{}} obj オブジェクト
       * @returns {{}}
       */
      filterObject(obj) {
        if (obj != null && typeof obj === 'object' && !Array.isArray(obj)) {
          Object.keys(obj).forEach(key => {
            if (obj[key] == null) {
              delete obj[key]
            }
            else {
              this.filterObject(obj[key])
            }
          })
        }
      },

      /****************************************
       * タイトルを共通化させる (半角に統一 & スペース除去 & 括弧除去)
       * @param {string} title タイトル
       * @returns {string} 共通化したタイトル
       */
      normalizeTitle(title = '') {
        return title
        // 英字の大文字を小文字に
        .toLowerCase()
        // 全角英数字を半角英数字に
        .replace(/[a-z0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
        // 話数の表記ゆれを統一
        .replace(/episode\s?(0*)(\d+)/, ' $2 ')
        .replace(/第?(0*)(\d+)話/g, ' $2 ')
        // 括弧を空白に
        .replace(/[-−\(\)()「」「」『』【】[]〈〉《》〔〕{}{}\[\]]/g, ' ')
        // 連続した空白を1文字分に
        .replace(/\s+/g, ' ')
        // 記号を全角から半角に
        .replace(/./g, s => ({
          '〜': '~',
          '?': '?',
          '!': '!',
          '”': '"',
          '’': "'",
          '´': "'",
          '`': '`',
          ':': ':',
          ',': ',',
          '・': '・',
          '/': '/',
          '#': '#',
          '$': '$',
          '%': '%',
          '&': '&',
          '=': '=',
          '@': '@',
        }[s] || s))
        .trim()
      },

      /****************************************
       * HTMLテキストから要素を生成
       * @param {string} html HTMLテキスト
       * @returns {HTMLElement} 生成した要素
       */
      generateElementByHTML(html) {
        const elem = document.createElement('div')
        elem.insertAdjacentHTML('beforeend', html)
        return elem.firstElementChild
      },
    },
  }

  unsafeWindow.ECT = ECT

  //----------------------------------------
  // fetchを上書き
  //----------------------------------------
  unsafeWindow.fetch = new Proxy(unsafeWindow.fetch, {
    apply: async function (target, thisArg, argumentsList) {
      const promise = Reflect.apply(target, thisArg, argumentsList)

      if (argumentsList[0] !== ECT.api.THREADS) {
        return promise
      }

      //----------------------------------------
      // 情報を取得
      //----------------------------------------
      /** @type {{} | undefined} */
      let videoData
      /** @type {{} | undefined} */
      let extraVideoData
      /** @type {{ videoData: any; threads: any; } | undefined} */
      let linkedVideo
      /** @type {{ videoData: any; threads: any; } | undefined} */
      let identicalVideo
      /** @type {[]} */
      let extraThreadsData = []
      /** @type {[]} */
      let channelThreadsData = []

      try {
        document.querySelector('.FormattedNumber-addedComment')?.remove()
        document.querySelector('.FormattedNumber-extraView')?.remove()

        console.log('[ECT] %cfetch start%c', 'color:white;background-color:blue;', '')

        // 動画情報
        videoData = await ECT.api.getVideoData(ECT.videoId)
        if (!(videoData?.channel?.isOfficialAnime)) {
          return promise
        }
        console.log('[ECT] videoData:', videoData)

        // dアニメストア
        if (videoData.channel.id === ECT.DANIME_CHANNEL_ID) {
          // 引用コメントの情報
          extraThreadsData = ECT.getExtraThreadsData(videoData)
          if (extraThreadsData.length !== 0) {
            extraVideoData = await ECT.api.getVideoData(extraThreadsData[0].videoId)
          }
        }
        // アニメチャンネル
        else if (
          !config['deleteExtra'] &&
          config['extraMainFromDanime']
        ) {
          // 関連付けられた動画
          linkedVideo = await ECT.getLinkedVideo(ECT.videoId)
          if (
            linkedVideo !== void 0 &&
            // 2秒差ならコメントを引用
            Math.abs(videoData.video.duration - linkedVideo.videoData.video.duration) <= 2
          ) {
            extraThreadsData = ECT.getChannnelThreadsData(linkedVideo.videoData)
            if (extraThreadsData.length !== 0) {
              extraVideoData = linkedVideo.videoData
            }
          }
        }
        console.log('[ECT] linkedVideo:', linkedVideo)

        // コメントを強制的に引用
        if (
          !config['deleteExtra'] &&
          config['forcedExtra'] &&
          extraThreadsData.length === 0 &&
          (
            videoData.channel.id === ECT.DANIME_CHANNEL_ID ||
            config['extraMainFromDanime']
          )
        ) {
          // 検索で一致した動画
          identicalVideo = await ECT.getIdenticalVideo(videoData)
          if (identicalVideo !== void 0) {
            extraThreadsData = ECT.getChannnelThreadsData(identicalVideo.videoData)
            if (extraThreadsData.length !== 0) {
              extraVideoData = identicalVideo.videoData
            }
          }
        }
        console.log('[ECT] identicalVideo:', identicalVideo)
        console.log('[ECT] extraThreadsData:', extraThreadsData)

        // チャンネルコメントの情報
        channelThreadsData = ECT.getChannnelThreadsData(videoData)
        console.log('[ECT] channelThreadsData:', channelThreadsData)

        console.log('[ECT] extraVideoData:', extraVideoData)

        if (extraVideoData == null) {
          return promise
        }
      } catch (e) {
        console.error(e)
      }

      const response = await promise
      const json = await response.json()
      console.log('[ECT] json:', json)

      //----------------------------------------
      // コメントデータを弄る
      //----------------------------------------
      if (!!(json.data?.threads?.length)) {
        let addedCommentCount = 0

        for (const extraThreadData of extraThreadsData) {
          try {
            // コメントデータ内の引用コメントのIndex
            const extraThreadIdx = json.data.threads.findIndex(thread => (
              extraThreadData.id.toString() === thread.id &&
              extraThreadData.forkLabel === thread.fork
            ))

            if (
              config['deleteExtra'] &&
              extraThreadIdx !== -1
            ) {
              // 引用したコメントを非表示
              delete json.data.threads[extraThreadIdx]
            }
            else {
              // 統合先のコメントの情報
              const targetThreadData = channelThreadsData.find(channelThread => (
                extraThreadData.forkLabel === channelThread.forkLabel &&
                extraThreadData.label.indexOf(channelThread.label) !== -1
              ))
              if (targetThreadData === void 0) {
                continue
              }

              // 統合先のコメントのIndex
              const targetThreadIdx = json.data.threads.findIndex(thread => (
                targetThreadData.id.toString() === thread.id &&
                targetThreadData.forkLabel === thread.fork
              ))
              if (targetThreadIdx === -1) {
                continue
              }

              if (
                // 引用コメントをチャンネルコメントに統合する
                config['extraMain'] && extraThreadData.forkLabel === 'main' ||
                // 引用かんたんコメントをかんたんコメントに統合する
                config['extraEasy'] && extraThreadData.forkLabel === 'easy' && !config['deleteEasy'] ||
                // コメントを強制的に引用する
                config['forcedExtra'] && identicalVideo !== void 0
              ) {
                let extraThread
                // コメントデータ内に引用コメントが存在する場合
                if (extraThreadIdx !== -1) {
                  extraThread = json.data.threads[extraThreadIdx]
                  delete json.data.threads[extraThreadIdx]
                }
                // 関連付けられた動画 or 検索して取得した動画
                else {
                  extraThread = (linkedVideo || identicalVideo)?.threads?.find(thread => (
                    extraThreadData.id.toString() === thread.id &&
                    extraThreadData.forkLabel === thread.fork
                  ))
                }
                if (extraThread == null) {
                  continue
                }

                addedCommentCount += extraThread.commentCount

                // コメントを統合
                json.data.threads[targetThreadIdx].comments.push(...extraThread.comments)
                // コメントを投稿順にソート
                json.data.threads[targetThreadIdx].comments.sort((a, b) => new Date(a.postedAt).getTime() - new Date(b.postedAt).getTime())
                json.data.threads[targetThreadIdx].comments.forEach((v, i) => v.no = i + 1)
                // コメント数を更新
                json.data.threads[targetThreadIdx].commentCount += extraThread.commentCount
              }
            }
            json.data.threads = json.data.threads.filter(Boolean)
          } catch (e) {
            console.error(e)
          }
        }

        // かんたんコメントを非表示にする
        if (config['deleteEasy']) {
          json.data.threads = json.data.threads.filter(v => v.fork !== 'easy')
        }

        // 統合したコメントの数をコメント数横に表示する
        if (
          config['showAddedCommentCount'] &&
          addedCommentCount !== 0
        ) {
          setTimeout(cnt => {
            const counter = document.querySelector('.CommentCountMeta-counter > .FormattedNumber')
            counter?.insertAdjacentHTML('afterend',
              `<span class="FormattedNumber-addedComment">&nbsp;(+${cnt.toLocaleString()})</span>`
            )
          }, 0, addedCommentCount)
        }

        console.log(`[ECT] 統合した引用コメント数: ${addedCommentCount}`)
      }

      // 引用した動画の再生数を再生数横に表示する
      if (
        config['showExtraViewCount'] &&
        !!(extraVideoData?.video?.count?.view)
      ) {
        setTimeout(cnt => {
          const counter = document.querySelector('.VideoViewCountMeta-counter > .FormattedNumber')
          counter?.insertAdjacentHTML('afterend',
            `<span class="FormattedNumber-extraView">&nbsp;(+${cnt.toLocaleString()})</span>`
          )
        }, 0, extraVideoData.video.count.view)
      }

      return new Response(JSON.stringify(json), {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      })
    },
  })

  //----------------------------------------
  // 監視
  //----------------------------------------
  const obs_opt = {
    childList: true,
    subtree: true,
  }
  const obs = new MutationObserver(mutationRecord => {
    for (const { addedNodes } of mutationRecord) {
      for (const added of addedNodes) {
        if (
          added instanceof HTMLElement &&
          added.classList.contains('ContextMenu-wrapper')
        ) {
          obs.disconnect()

          // 設定ボタンを右クリックメニューに追加
          const menuContainer = added.getElementsByClassName('VideoContextMenuContainer')[0]
          const ectOptionBtn = ECT.util.generateElementByHTML(
            `
            <div class="VideoContextMenu-group">
              <div class="ContextMenuItem">引用コメントツール 設定</div>
            </div>
            `
          )
          ectOptionBtn.firstElementChild.addEventListener('click', GM_config.open)
          menuContainer?.appendChild(ectOptionBtn)

          obs.observe(document.body, obs_opt)
        }
      }
    }
  })
  obs.observe(document.body, obs_opt)
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址