// ==UserScript==
// @name ニコニコ動画 引用コメントツール
// @namespace https://midra.me
// @version 1.6.2
// @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,
},
kawaiiPct: {
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 <= 1) {
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(/[##](0*)(\d+)\s*/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)
}
// コメントの「かわいい」率を表示する
if (config['kawaiiPct']) {
let cmtCnt = 0
let kawaiiCnt = 0
for (const thread of json.data.threads) {
for (const comment of thread.comments) {
cmtCnt++
if (comment.body.indexOf('かわいい') !== -1) {
kawaiiCnt++
}
}
}
const kawaiiPct = Math.round(kawaiiCnt / cmtCnt * 10 * 100) / 10
if (0 < kawaiiPct) {
setTimeout(pct => {
try {
const kawaiiPctElem =
document.querySelector('.KawaiiPctMeta') ||
document.querySelector('.CommentCountMeta')?.cloneNode(true)
if (kawaiiPctElem instanceof HTMLElement) {
kawaiiPctElem.classList.add('KawaiiPctMeta')
kawaiiPctElem.querySelector('.CommentCountMeta-title').textContent = 'かわいい率'
kawaiiPctElem.querySelector('.CommentCountMeta-counter').textContent = `${pct}%`
document.querySelector('.CommentCountMeta').insertAdjacentElement('afterend', kawaiiPctElem)
}
} catch (e) {
console.error(e)
}
}, 0, kawaiiPct)
}
}
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)
})()