您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Display overlay of comments on various streaming sites and EPGStation.
// ==UserScript== // @name Anime Comment Overlay // @namespace https://github.com/SlashNephy // @version 0.4.1 // @author SlashNephy // @description Display overlay of comments on various streaming sites and EPGStation. // @description:ja アニメ配信サイト (dアニメストア / ABEMAビデオ / Netflix) や EPGStation で実況コメをオーバーレイ表示します。 // @homepage https://scrapbox.io/slashnephy/%E3%82%A2%E3%83%8B%E3%83%A1%E9%85%8D%E4%BF%A1%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E5%AE%9F%E6%B3%81%E3%82%B3%E3%83%A1%E3%82%92%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript // @homepageURL https://scrapbox.io/slashnephy/%E3%82%A2%E3%83%8B%E3%83%A1%E9%85%8D%E4%BF%A1%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E5%AE%9F%E6%B3%81%E3%82%B3%E3%83%A1%E3%82%92%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript // @icon https://www.google.com/s2/favicons?sz=64&domain=animestore.docomo.ne.jp // @supportURL https://github.com/SlashNephy/userscripts/issues // @match https://animestore.docomo.ne.jp/animestore/sc_d_pc?partId=* // @match https://abema.tv/video/episode/* // @match https://www.netflix.com/watch/* // @match *://*/* // @require https://cdn.jsdelivr.net/npm/@xpadev-net/[email protected]/dist/bundle.min.js // @require https://cdn.jsdelivr.net/gh/NaturalIntelligence/fast-xml-parser@ecf6016f9b48aec1a921e673158be0773d07283e/lib/fxp.min.js // @connect cal.syoboi.jp // @grant GM_xmlhttpRequest // @license MIT license // ==/UserScript== (function (NiconiComments, fastXmlParser) { 'use strict'; const AnnictSupportedVodChannelIds = { bandai: 107, niconico: 165, dAnime: 241, amazonPrimeVideo: 243, netflix: 244, abemaVideo: 260, dAnimeNiconico: 306, }; const ChannelCmAttributes = { jk1: null, jk2: null, jk4: { head: 60, sponsor: 5, normal: 150, }, jk5: { head: 60 + 3, sponsor: 5, normal: 105, }, jk6: { head: 0, sponsor: 10, normal: 135, }, jk7: { head: 0, sponsor: 10, normal: 60, }, jk8: { head: 120, sponsor: 10, normal: 90, }, jk9: { head: 0, sponsor: 10, normal: 60, }, jk10: { head: 0, sponsor: 10, normal: 60, }, jk11: { head: 0, sponsor: 10, normal: 60, }, jk12: { head: 0, sponsor: 10, normal: 60, }, jk101: null, jk103: null, jk141: { head: 15, sponsor: 10, normal: 60, }, jk151: { head: 0, sponsor: 10, normal: 60, }, jk161: { head: 3, sponsor: 10, normal: 60, }, jk171: { head: 0, sponsor: 10, normal: 60, }, jk181: { head: 15, sponsor: 5, normal: 135, }, jk191: null, jk192: null, jk193: null, jk211: { head: 0, sponsor: 10, normal: 60, }, jk222: { head: 10, sponsor: 10, normal: 150, }, jk236: { head: 10, sponsor: 10, normal: 45, }, jk252: null, jk260: { head: 2, sponsor: 10, normal: 120, }, jk263: { head: 0, sponsor: 10, normal: 180, }, jk265: { head: 10, sponsor: 10, normal: 60, }, jk333: null, }; const vposAdjustment = 50; const partSymbols = ['A', 'B', 'C']; const partSymbolCommentsThreshold = 2; const partSymbolAdjustment = 3; const opSymbols = ['OP']; const opSymbolCommentsThreshold = 2; const opLength = 90; const opAdjustment = 30; const copyrightCmAttributes = [ { pattern: /^©BNP\//, adjustment: 3, }, ]; const maxPrograms = 5; const targetFps = 100; async function awaitElement(selectors) { return new Promise((resolve) => { const element = document.querySelector(selectors); if (element !== null) { resolve(element); return; } const observer = new MutationObserver(() => { const e = document.querySelector(selectors); if (e !== null) { resolve(e); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true, }); }); } async function fetchAnnictBroadcastData(branch = 'master') { const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/anime-vod-data/${branch}/dist/data.json`); return response.json(); } let observer = null; const AbemaVideoOverlay = { id: 'abema-video', name: 'ABEMAビデオ', url: /^https:\/\/abema\.tv\/video\/episode\/([\w-]+)/, initializeContainers() { const video = () => document.querySelector('video[preload="metadata"]'); const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; canvas.style.position = 'relative'; canvas.style.objectFit = 'contain'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.zIndex = '10'; awaitElement('.com-vod-VODScreen-video-cover') .then((cover) => { cover.appendChild(canvas); }) .catch((e) => { console.error(`[anime-comment-overlay] failed to find cover element: ${e}`); }); return { video, canvas }; }, async detectMedia(id) { const [titleId, episodeId] = id.split('_', 2); if (!titleId || !episodeId) { throw new Error(`unexpected id format: ${id}`); } const title = document.querySelector('.com-video-EpisodeTitleBlock__series-info')?.textContent; if (!title) { throw new Error('title container not found'); } const episode = document.querySelector('.com-video-EpisodeTitleBlock__title')?.textContent; if (!episode) { throw new Error('episode container not found'); } let [episodeNumber, episodeTitle] = episode.split(' ', 2); if (!episodeNumber || !episodeTitle) { episodeNumber = episode; episodeTitle = episode; } const broadcasts = await fetchAnnictBroadcastData(); return { work: { title, annictIds: broadcasts .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.abemaVideo && x.vod_code === titleId) .map((x) => x.work_id), }, episode: { title: episodeTitle, number: episodeNumber, }, }; }, addEventListener(event, callback) { switch (event) { case 'mediaChanged': { if (observer !== null) { observer.disconnect(); observer = null; } const target = document.querySelector('.com-video-EpisodeTitleBlock__title'); if (target === null) { throw new Error('target container not found'); } observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'characterData' && mutation.target === target) { callback(); } } }); observer.observe(target, { characterData: true, subtree: true }); } } }, removeEventListener(event) { switch (event) { case 'mediaChanged': { observer?.disconnect(); observer = null; } } }, }; async function fetchDanimePartInfo(partId) { const response = await fetch(`https://animestore.docomo.ne.jp/animestore/rest/WS030101?partId=${partId}`); return response.json(); } const DanimeOverlay = { id: 'danime-store', name: 'dアニメストア', url: /^https:\/\/animestore\.docomo\.ne\.jp\/animestore\/sc_d_pc\?partId=(\d+)/, initializeContainers() { const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; canvas.style.position = 'relative'; canvas.style.objectFit = 'contain'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.zIndex = '10'; awaitElement('video#video') .then((video) => { video.insertAdjacentElement('afterend', canvas); }) .catch((e) => { console.error(`[anime-comment-overlay] failed to find video element: ${e}`); }); const video = () => document.querySelector('video#video'); return { video, canvas }; }, async detectMedia(partId) { const info = await fetchDanimePartInfo(partId); const broadcasts = await fetchAnnictBroadcastData(); return { work: { title: info.workTitle, annictIds: broadcasts .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.dAnime && x.vod_code === info.workId) .map((x) => x.work_id), copyright: info.partCopyright, }, episode: { title: info.partTitle, number: info.partDispNumber, }, }; }, addEventListener(event, callback) { switch (event) { case 'mediaChanged': $('.backInfoTxt3').on('DOMSubtreeModified propertychange', callback); } }, removeEventListener(event, callback) { switch (event) { case 'mediaChanged': $('.backInfoTxt3').off('DOMSubtreeModified propertychange', callback); } }, }; async function fetchEpgStationRecordedItem(id) { const response = await fetch(`/api/recorded/${id}?isHalfWidth=true`); return await response.json(); } async function fetchEpgStationChannels() { const response = await fetch('/api/channels'); return await response.json(); } const EpgStationOnAirOverlay = { id: 'epgstation-onair', name: 'EPGStation (ライブ)', url: /^https?:\/\/.+\/#\/onair\/watch/, initializeContainers() { throw new Error('not implemented'); }, async detectMedia(partId) { throw new Error('not implemented'); }, addEventListener(event) { }, removeEventListener(event) { }, }; const EpgStationRecordedOverlay = { id: 'epgstation-recorded', name: 'EPGStation (録画番組)', url: /^https?:\/\/.+\/#\/recorded\/streaming\/\d+/, initializeContainers() { const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; canvas.style.position = 'absolute'; canvas.style.objectFit = 'contain'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.zIndex = '10'; awaitElement('.video-wrap video') .then((video) => { video.insertAdjacentElement('beforebegin', canvas); }) .catch((e) => { console.error(`[anime-comment-overlay] failed to find video element: ${e}`); }); const video = () => document.querySelector('.video-wrap video'); return { video, canvas }; }, async detectMedia() { const queries = new URLSearchParams(window.location.hash.split('?')[1]); const recordedId = queries.get('recordedId'); if (recordedId === null) { throw new Error('recordedId is null'); } const recorded = await fetchEpgStationRecordedItem(recordedId); const channels = await fetchEpgStationChannels(); const channel = channels.find((x) => x.id === recorded.channelId); if (channel === undefined) { throw new Error('failed to find channel'); } return { video: { channel: { type: channel.channelType, serviceId: channel.serviceId, }, startedAt: new Date(recorded.startAt), endedAt: new Date(recorded.endAt), }, }; }, addEventListener(event) { }, removeEventListener(event) { }, }; const executeGmXhr = async (request) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...request, onload: (response) => { resolve(response); }, onerror: (error) => { reject(error); }, }); }); async function fetchNetflixMediaMetadata(baseUrl, episodeId) { const { responseText } = await executeGmXhr({ method: 'GET', url: `${baseUrl}/metadata?movieid=${episodeId}`, }); return JSON.parse(responseText); } const NetflixOverlay = { id: 'netflix', name: 'Netflix', url: /^https:\/\/www\.netflix.com\/watch\/(\d+)/, initializeContainers() { const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; canvas.style.position = 'relative'; canvas.style.objectFit = 'contain'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.zIndex = '10'; awaitElement('video') .then((video) => { video.insertAdjacentElement('afterend', canvas); }) .catch((e) => { console.error(`[anime-comment-overlay] failed to find video element: ${e}`); }); const video = () => document.querySelector('video'); return { video, canvas }; }, async detectMedia(episodeId) { const reactContextScript = Array.from(document.getElementsByTagName('script')).find((e) => e.textContent?.includes('reactContext') === true); if (reactContextScript === undefined) { throw new Error('failed to find reactContext script'); } const reactContextJson = reactContextScript.textContent ?.replace(/^.+reactContext = (.+);$/, '$1') .replace(/\\x(.{2})/g, (_, x) => String.fromCharCode(parseInt(x, 16))); if (reactContextJson === undefined) { throw new Error('failed to extract reactContext json'); } const context = JSON.parse(reactContextJson); const metadata = await fetchNetflixMediaMetadata(`${context.models.services.data.memberapi.protocol}://${context.models.services.data.memberapi.hostname}${context.models.services.data.memberapi.path[0]}`, episodeId); const episode = metadata.video.seasons .flatMap((s) => s.episodes) .find((e) => e.episodeId === metadata.video.currentEpisode); if (episode === undefined) { throw new Error('failed to find episode'); } const broadcasts = await fetchAnnictBroadcastData(); return { work: { title: metadata.video.title, annictIds: broadcasts .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.netflix && x.vod_code === metadata.video.id.toString()) .map((x) => x.work_id), }, episode: { title: episode.title, number: episode.seq, }, }; }, addEventListener(event, callback) { switch (event) { case 'mediaChanged': document.addEventListener('popstate', callback); } }, removeEventListener(event, callback) { switch (event) { case 'mediaChanged': { document.removeEventListener('popstate', callback); } } }, }; var dist = {}; var utils = {}; var oldJapaneseNumerics$1 = {}; Object.defineProperty(oldJapaneseNumerics$1, "__esModule", { value: true }); const oldJapaneseNumerics = { 零: '〇', 壱: '一', 壹: '一', 弐: '二', 弍: '二', 貳: '二', 貮: '二', 参: '三', 參: '三', 肆: '四', 伍: '五', 陸: '六', 漆: '七', 捌: '八', 玖: '九', 拾: '十', 廿: '二十', 陌: '百', 佰: '百', 阡: '千', 仟: '千', 萬: '万' }; oldJapaneseNumerics$1.default = oldJapaneseNumerics; var japaneseNumerics$1 = {}; Object.defineProperty(japaneseNumerics$1, "__esModule", { value: true }); const japaneseNumerics = { 〇: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 }; japaneseNumerics$1.default = japaneseNumerics; (function (exports) { Object.defineProperty(exports, "__esModule", { value: true }); exports.zen2han = exports.n2kan = exports.kan2n = exports.splitLargeNumber = exports.normalize = exports.smallNumbers = exports.largeNumbers = void 0; const oldJapaneseNumerics_1 = oldJapaneseNumerics$1; const japaneseNumerics_1 = japaneseNumerics$1; exports.largeNumbers = { '兆': 1000000000000, '億': 100000000, '万': 10000 }; exports.smallNumbers = { '千': 1000, '百': 100, '十': 10 }; function normalize(japanese) { for (const key in oldJapaneseNumerics_1.default) { const reg = new RegExp(key, 'g'); japanese = japanese.replace(reg, oldJapaneseNumerics_1.default[key]); } return japanese; } exports.normalize = normalize; /** * 漢数字を兆、億、万単位に分割する */ function splitLargeNumber(japanese) { let kanji = japanese; const numbers = {}; for (const key in exports.largeNumbers) { const reg = new RegExp(`(.+)${key}`); const match = kanji.match(reg); if (match) { numbers[key] = kan2n(match[1]); kanji = kanji.replace(match[0], ''); } else { numbers[key] = 0; } } if (kanji) { numbers['千'] = kan2n(kanji); } else { numbers['千'] = 0; } return numbers; } exports.splitLargeNumber = splitLargeNumber; /** * 千単位以下の漢数字を数字に変換する(例: 三千 => 3000) * * @param japanese */ function kan2n(japanese) { if (japanese.match(/^[0-9]+$/)) { return Number(japanese); } let kanji = zen2han(japanese); let number = 0; for (const key in exports.smallNumbers) { const reg = new RegExp(`(.*)${key}`); const match = kanji.match(reg); if (match) { let n = 1; if (match[1]) { if (match[1].match(/^[0-9]+$/)) { n = Number(match[1]); } else { n = japaneseNumerics_1.default[match[1]]; } } number = number + n * exports.smallNumbers[key]; kanji = kanji.replace(match[0], ''); } } if (kanji) { if (kanji.match(/^[0-9]+$/)) { number = number + Number(kanji); } else { for (let index = 0; index < kanji.length; index++) { const char = kanji[index]; const digit = kanji.length - index - 1; number = number + japaneseNumerics_1.default[char] * 10 ** digit; } } } return number; } exports.kan2n = kan2n; /** * Converts number less than 10000 to kanji. * * @param num */ function n2kan(num) { const kanjiNumbers = Object.keys(japaneseNumerics_1.default); let number = num; let kanji = ''; for (const key in exports.smallNumbers) { const n = Math.floor(number / exports.smallNumbers[key]); if (n) { number = number - n * exports.smallNumbers[key]; if (1 === n) { kanji = `${kanji}${key}`; } else { kanji = `${kanji}${kanjiNumbers[n]}${key}`; } } } if (number) { kanji = `${kanji}${kanjiNumbers[number]}`; } return kanji; } exports.n2kan = n2kan; /** * Converts double-width number to number as string. * * @param num */ function zen2han(str) { return str.replace(/[0-9]/g, s => { return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); }); } exports.zen2han = zen2han; })(utils); Object.defineProperty(dist, "__esModule", { value: true }); var findKanjiNumbers_1 = dist.findKanjiNumbers = dist.number2kanji = kanji2number_1 = dist.kanji2number = void 0; const utils_1 = utils; const japaneseNumerics_1 = japaneseNumerics$1; function kanji2number(japanese) { japanese = (0, utils_1.normalize)(japanese); if (japanese.match('〇') || japanese.match(/^[〇一二三四五六七八九]+$/)) { for (const key in japaneseNumerics_1.default) { const reg = new RegExp(key, 'g'); japanese = japanese.replace(reg, japaneseNumerics_1.default[key].toString()); } return Number(japanese); } else { let number = 0; const numbers = (0, utils_1.splitLargeNumber)(japanese); // 万以上の数字を数値に変換 for (const key in utils_1.largeNumbers) { if (numbers[key]) { const n = utils_1.largeNumbers[key] * numbers[key]; number = number + n; } } if (!Number.isInteger(number) || !Number.isInteger(numbers['千'])) { throw new TypeError('The attribute of kanji2number() must be a Japanese numeral as integer.'); } // 千以下の数字を足す return number + numbers['千']; } } var kanji2number_1 = dist.kanji2number = kanji2number; function number2kanji(num) { if (!num.toString().match(/^[0-9]+$/)) { throw new TypeError('The attribute of number2kanji() must be integer.'); } Object.keys(japaneseNumerics_1.default); let number = num; let kanji = ''; // 万以上の数字を漢字に変換 for (const key in utils_1.largeNumbers) { const n = Math.floor(number / utils_1.largeNumbers[key]); if (n) { number = number - n * utils_1.largeNumbers[key]; kanji = `${kanji}${(0, utils_1.n2kan)(n)}${key}`; } } if (number) { kanji = `${kanji}${(0, utils_1.n2kan)(number)}`; } return kanji || '〇'; } dist.number2kanji = number2kanji; function findKanjiNumbers(text) { const num = '([0-90-9]*)|([〇一二三四五六七八九壱壹弐弍貳貮参參肆伍陸漆捌玖]*)'; const basePattern = `((${num})(千|阡|仟))?((${num})(百|陌|佰))?((${num})(十|拾))?(${num})?`; const pattern = `((${basePattern}兆)?(${basePattern}億)?(${basePattern}(万|萬))?${basePattern})`; const regex = new RegExp(pattern, 'g'); const match = text.match(regex); if (match) { return match.filter(item => { if (!item.match(/^[0-90-9]+$/) && item.length && '兆' !== item && '億' !== item && '万' !== item && '萬' !== item) { return true; } else { return false; } }); } else { return []; } } findKanjiNumbers_1 = dist.findKanjiNumbers = findKanjiNumbers; /** * Checks whether given array's length is equal to given number. * * @example * ```ts * hasLength(arr, 1) // equivalent to arr.length === 1 * ``` */ /** * Checks whether given array's length is greather than or equal to given number. * * @example * ```ts * hasMinLength(arr, 1) // equivalent to arr.length >= 1 * ``` */ function hasMinLength(arr, length) { return arr.length >= length; } async function fetchArmEntries(branch = 'master') { const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/arm-supplementary/${branch}/dist/arm.json`); return response.json(); } async function fetchSayaDefinitions(branch = 'master') { const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/saya-definitions/${branch}/definitions.json`); return response.json(); } async function fetchSyobocalProgLookup(tids) { const { responseText } = await executeGmXhr({ url: `https://cal.syoboi.jp/db.php?Command=ProgLookup&TID=${tids.join(',')}`, }); const parser = new fastXmlParser.XMLParser(); return parser.parse(responseText); } async function fetchSyobocalProgLookupWithRange(startTime, endTime, chId) { function zerofill(n) { return `00${n}`.slice(-2); } function format(d) { return `${d.getFullYear()}${zerofill(d.getMonth() + 1)}${zerofill(d.getDate())}_${zerofill(d.getHours())}${zerofill(d.getMinutes())}${zerofill(d.getSeconds())}`; } const { responseText } = await executeGmXhr({ url: `https://cal.syoboi.jp/db.php?Command=ProgLookup&Range=${format(startTime)}-${format(endTime)}&ChID=${chId}`, }); const parser = new fastXmlParser.XMLParser(); return parser.parse(responseText); } async function findPrograms(media) { const saya = await fetchSayaDefinitions(); const serviceId = media.video?.channel.serviceId; if (serviceId !== undefined && media.video !== undefined) { const chId = saya.channels.find((x) => x.type === media.video?.channel.type && x.serviceIds.includes(serviceId)) ?.syobocalId; if (chId !== undefined) { const programs = await fetchSyobocalProgLookupWithRange(media.video.startedAt, media.video.endedAt, chId); return convertPrograms(programs, undefined, saya); } } if (media.work?.annictIds.length === 0) { return []; } const arm = await fetchArmEntries(); const syobocalTids = arm .filter((e) => e.annict_id !== undefined && media.work?.annictIds.includes(e.annict_id)) .map((e) => e.syobocal_tid) .filter((x) => x !== undefined) .filter((x, idx, array) => idx === array.indexOf(x)); console.info(`[anime-comment-overlay] found syobocal tids: ${syobocalTids}`); const programs = await fetchSyobocalProgLookup(syobocalTids); const episodeNumber = extractEpisodeNumber(media.episode?.number); return convertPrograms(programs, episodeNumber, saya); } function convertPrograms(response, episodeNumber, saya) { const items = Array.isArray(response.ProgLookupResponse?.ProgItems?.ProgItem) ? response.ProgLookupResponse?.ProgItems?.ProgItem : [response.ProgLookupResponse?.ProgItems?.ProgItem]; return (items ?.filter((p) => p !== undefined) .filter((p) => episodeNumber === undefined || p.Count === episodeNumber) ?.map((p) => { const startedAt = Date.parse(p.StTime) / 1000; if (Date.now() / 1000 < startedAt) { return null; } const endedAt = Date.parse(p.EdTime) / 1000; if (Date.now() / 1000 < endedAt) { return null; } const channel = saya.channels.find((c) => c.syobocalId === p.ChID); if (channel === undefined) { return null; } console.info(`[anime-comment-overlay] found program: ${channel.name} (${p.StTime} ~ ${p.EdTime})`); return { channel, startedAt, endedAt, }; }) ?.filter((x) => x !== null) ?.sort((a, b) => a.startedAt - b.startedAt) ?? []); } function extractEpisodeNumber(text) { if (typeof text === 'number') { return text; } if (text === undefined) { return undefined; } text = text.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248)); const match = /\d+(\.\d+)?/.exec(text.replace(',', '')); if (match && hasMinLength(match, 1)) { return parseFloat(match[0]); } const kanjis = findKanjiNumbers_1(text); if (hasMinLength(kanjis, 1)) { return kanji2number_1(kanjis[0]); } } async function* fetchComments(providers, media, programs) { const promises = providers .map((provider) => programs.map(async (program) => provider .provide(media, program) .then((comments) => { if (comments.length === 0) { return []; } console.info(`[anime-comment-overlay] fetched ${comments.length} comments from ${provider.name}`); return comments.map((c) => ({ id: c.id * c.providerId, vpos: c.vpos, content: c.content, date: c.date, date_usec: c.dateUsec, user_id: c.userId * c.providerId, owner: !c.userId, premium: c.isPremium, mail: c.mails, layer: c.layer, })); }) .catch((e) => { console.error(`[anime-comment-overlay] failed to comments from ${provider.name}: ${e}`); return []; }))) .flat(); for (const promise of promises) { yield promise; } } async function fetchNiconicoJikkyoKakoLog({ channel, startTime, endTime, }) { const response = await fetch(`https://jikkyo.tsukumijima.net/api/kakolog/${channel}?starttime=${startTime}&endtime=${endTime}&format=json`); return response.json(); } const NiconicoJikkyoKakoLogProvider = { name: 'ニコニコ実況過去ログ', async provide(media, program) { const jkId = program.channel.nicojkId; if (jkId === undefined) { return []; } const request = { channel: `jk${jkId}`, startTime: program.startedAt, endTime: program.endedAt, }; const response = await fetchNiconicoJikkyoKakoLog(request); const chats = convertChats(response); const attr = ChannelCmAttributes[request.channel]; if (media.video !== undefined) { console.info('[anime-comment-overlay] this media is video', media); } else if (attr === null) { console.info(`[anime-comment-overlay] channel ${request.channel} does not have CM`, program); } else { console.log(`[anime-comment-overlay] CM attribute for channel ${request.channel}`, attr, program); processHeadCms(chats, attr.head, program); for (const symbol of partSymbols) { processIntervalCms(chats, symbol, attr.normal, attr.sponsor, program); } } let copyrightAdjustment = 0; const copyright = media.work?.copyright; if (copyright !== undefined) { const attr2 = copyrightCmAttributes.find((a) => a.pattern.test(copyright)); if (attr2 !== undefined) { copyrightAdjustment = attr2.adjustment; console.info(`[anime-comment-overlay] copyright adjustment for ${copyright}: ${copyrightAdjustment}`, program); } } return (chats .filter((c) => !c.isDeleted) .map((c) => ({ ...c, vpos: Math.max(copyrightAdjustment + (c.date - request.startTime) * 100 + Math.floor(c.dateUsec / 10000) - vposAdjustment, 0), }))); }, }; function convertChats(response) { if ('error' in response) { console.error(`[anime-comment-overlay] received error from niconico jikkyo kako log: ${response.error}`); return []; } const users = []; return (response.packet .filter(({ chat }) => chat.deleted !== '1' && chat.abone !== '1') .map(({ chat }) => { const mails = chat.mail ? chat.mail.split(/\s+/g) : []; if (chat.content.startsWith('/')) { mails.push('invisible'); } let userId = users.indexOf(chat.user_id); if (userId < 0) { userId = users.length; users.push(chat.user_id); } return { providerId: 1, id: parseInt(chat.no, 10), vpos: 0, content: chat.content, date: parseInt(chat.date, 10), dateUsec: chat.date_usec ? parseInt(chat.date_usec, 10) : Math.floor(Math.random() * 100000), userId, isPremium: chat.premium === '1', mails, layer: -1, isDeleted: false, }; })); } function processHeadCms(comments, headInterval, program) { if (headInterval === 0) { return; } let removes = 0; const cmStartTime = program.startedAt; const cmEndTime = program.startedAt + headInterval; for (const comment of comments.filter((c) => cmStartTime < c.date && c.date <= cmEndTime)) { comment.isDeleted = true; removes++; } console.info(`[anime-comment-overlay] CM part: head (${removes} comments deleted)`, program); let shifts = 0; for (const comment of comments.filter((c) => cmEndTime < c.date)) { comment.date -= headInterval; shifts++; } console.info(`[anime-comment-overlay] CM part: head (${shifts} comments shifted)`, program); } function processIntervalCms(comments, symbol, normalInterval, sponsorInterval, program) { const partComments = comments.filter((c) => c.content === symbol); if (!hasMinLength(partComments, partSymbolCommentsThreshold)) { return; } if (partSymbols.indexOf(symbol) === 0) { const opComments = comments.filter((c) => opSymbols.includes(c.content)); if (hasMinLength(opComments, opSymbolCommentsThreshold)) { const opStartTime = opComments[0].date; const opEndTime = opStartTime + opLength; if (opStartTime < partComments[0].date && partComments[0].date < opEndTime + opAdjustment) { console.info(`[anime-comment-overlay] OP part: ${symbol}`, program); return; } } } let removes = 0; const effectiveCmLength = normalInterval + (partSymbols.indexOf(symbol) === 0 ? sponsorInterval : 0); const cmEndTime = partComments[0].date - partSymbolAdjustment; const cmStartTime = cmEndTime - effectiveCmLength; for (const comment of comments.filter((c) => cmStartTime < c.date && c.date <= cmEndTime)) { comment.isDeleted = true; removes++; } console.info(`[anime-comment-overlay] CM part: ${symbol} (${removes} comments deleted)`, program); let shifts = 0; for (const comment of comments.filter((c) => cmEndTime < c.date)) { comment.date -= effectiveCmLength; shifts++; } console.info(`[anime-comment-overlay] CM part: ${symbol} (${shifts} comments shifted)`, program); } const overlays = [ DanimeOverlay, AbemaVideoOverlay, NetflixOverlay, EpgStationOnAirOverlay, EpgStationRecordedOverlay, ]; const providers = [NiconicoJikkyoKakoLogProvider]; async function initializeOverlay(overlay, params) { const media = await overlay.detectMedia(...params); console.log('[anime-comment-overlay] media', media); const programs = await findPrograms(media); console.log('[anime-comment-overlay] programs', programs); const { video, canvas } = overlay.initializeContainers(); const renderer = new NiconiComments(canvas, undefined, { format: 'empty', }); let isInitialized = false; let cachedVideo = null; const interval = window.setInterval(() => { if (!isInitialized) { return; } let time; if (typeof video === 'function') { if (cachedVideo?.isConnected !== true) { cachedVideo = video(); if (cachedVideo === null) { return; } } time = cachedVideo.currentTime; } else { time = video.currentTime; } setTimeout(() => { const vpos = Math.floor(time * 100); renderer.drawCanvas(vpos); }, 0); }, 1000 / targetFps); function onMediaChanged() { overlay.removeEventListener('mediaChanged', onMediaChanged); clearInterval(interval); renderer.clear(); canvas.remove(); initializeOverlays().catch(console.error); console.info('[anime-comment-overlay] media changed'); } overlay.addEventListener('mediaChanged', onMediaChanged); for await (const comments of fetchComments(providers, media, programs.slice(0, maxPrograms))) { setTimeout(() => { renderer.addComments(...comments); }, 0); } isInitialized = true; } async function initializeOverlays() { for (const overlay of overlays) { const params = overlay.url.exec(window.location.href)?.slice(1); if (params === undefined) { continue; } console.info(`[anime-comment-overlay] initializing ${overlay.id}`, params); await initializeOverlay(overlay, params); console.info(`[anime-comment-overlay] initialized ${overlay.id}`, params); break; } } initializeOverlays().catch(console.error); })(NiconiComments, fxp);
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址