Greasy Fork镜像 还支持 简体中文。

Anime Comment Overlay

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或关注我们的公众号极客氢云获取最新地址