您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
dアニメストア ニコニコ支店の引用コメント関連のツール
当前为
// ==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"> (+${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"> (+${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或关注我们的公众号极客氢云获取最新地址