您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Abemaのコメントをニコニコ風に流すやつ
当前为
// ==UserScript== // @name Abema ニコニコ風コメント (Beta) // @namespace https://midra.me // @version 0.0.2 // @description Abemaのコメントをニコニコ風に流すやつ // @author Midra // @license MIT // @match https://abema.tv/* // @icon https://www.google.com/s2/favicons?sz=64&domain=abema.tv // @run-at document-body // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @compatible chrome >=84 // @compatible safari >=15 // @require https://greasyfork.org/scripts/444119-flowcomments/code/FlowComments.js?version=1046016 // ==/UserScript== // @ts-check /* jshint esversion: 6 */ 'use strict' /**************************************** * FlowComments for Abema ****************************************/ { //---------------------------------------- // FlowComments //---------------------------------------- const fc = new FlowComments({ autoResize: true, autoAdjRes: true, }) // @ts-ignore unsafeWindow.mid_fc = fc /** * @typedef {object} AbmSlotStats * @property {number} view 視聴数 * @property {number} comment コメント数 */ /**************************************** * @classdesc Abemaのコメント */ class AbmComment { /** * コメントID * @type {string} */ #id /** * ユーザーID * @type {string} */ #userId /** * コメント本文 * @type {string} */ #message /** * 投稿日時 * @type {number} */ #createdAtMs /**************************************** * コンストラクタ * @param {object} comment コメント */ constructor(comment) { this.#id = comment.id this.#userId = comment.userId this.#message = comment.message this.#createdAtMs = comment.createdAtMs } get id() { return this.#id } get userId() { return this.#userId } get message() { return this.#message } get createdAtMs() { return this.#createdAtMs } } /**************************************** * @classdesc Abemaの放送枠 */ class AbmSlot { /** * ID * @type {string} */ id /** * チャンネルID * @type {string} */ channelId /** * タイトル * @type {string} */ title /** * 放送開始日時 * @type {number} */ startAt /** * 放送終了日時 * @type {number} */ endAt /** * 視聴数、コメント数 * @type {AbmSlotStats} */ stats /**************************************** * コンストラクタ * @param {object} slot 放送枠(API) */ constructor(slot) { this.id = slot.id this.channelId = slot.channelId this.title = slot.title this.startAt = slot.startAt this.endAt = slot.endAt this.stats = slot.stats } /** * 放送中かどうか * @type {boolean} */ get isOnair() { if (Number.isFinite(this.startAt) && Number.isFinite(this.endAt)) { const now = Date.now() const isStarted = this.startAt * 1000 <= now const isEnded = this.endAt * 1000 < now return isStarted && !isEnded } else { return false } } } /**************************************** * ABMCMTオブジェクト */ const ABMCMT = {} /**************************************** * キャッシュ */ ABMCMT.temp = { /** * .com-a-Video__video * @type {HTMLElement} */ video: null, /** * チャンネル情報一覧みたいなやつ * @type {Array<AbmSlot>} */ slots: [], /** * 最新の取得済みコメントの投稿日時 * @type {number} */ latestCreatedAtMs: null, /**************************************** * cacheリセット */ reset() { this.video = null this.slots = [] this.latestCreatedAtMs = null }, } /**************************************** * 設定関連 */ ABMCMT.config = { /** * コメント取得間隔 * @type {number} */ INTERVAL_TIME: 7000, /** * リクエストのオプション * @type {RequestInit} */ get requestInit() { return { method: 'GET', mode: 'cors', headers: { 'Content-Type': 'application/json', 'Authorization': `bearer ${window.localStorage.getItem('abm_token')}`, }, referrerPolicy: 'strict-origin-when-cross-origin', } }, /**************************************** * API関連 */ api: { base: 'https://api.abema.io', /** * 放送中のチャンネル情報一覧みたいなやつ * @type {string} */ get broadcastSlots() { return `${this.base}/v1/broadcast/slots` }, /**************************************** * コメント取得 * @param {string} slotId 放送枠のID * @param {number} [since] `since`以降のコメントを取得 * @param {number} [limit] 取得するコメント数上限 * @returns {string} URL */ comments(slotId, since, limit = 100) { /** @type {Record<string, string>} */ const params = {} if (Number.isFinite(since)) { params.since = since.toFixed() } if (Number.isFinite(limit)) { params.limit = limit.toFixed() } return `${this.base}/v1/slots/${slotId}/comments?${new URLSearchParams(params).toString()}` }, }, } /**************************************** * ユーティリティ */ ABMCMT.util = { /**************************************** * 放送枠関連 */ slot: { /**************************************** * 放送枠一覧を取得(都度更新) * @returns {Promise<Array<AbmSlot>>} 放送枠一覧 */ async fetch() { const res = await fetch(ABMCMT.config.api.broadcastSlots) const { slots } = await res.json() if (Array.isArray(slots) && slots.length !== 0) { ABMCMT.temp.slots = slots.map(v => new AbmSlot(v)) return ABMCMT.temp.slots } else { throw new Error('slots is empty') } }, /**************************************** * 放送枠一覧を取得 * @returns {Promise<Array<AbmSlot>>} 放送枠一覧 */ async getAll() { if (ABMCMT.temp.slots.length !== 0) { return ABMCMT.temp.slots } else { return await this.fetch() } }, /**************************************** * チャンネルIDを指定して放送枠を取得 * @param {string} channelId チャンネルID * @returns {Promise<AbmSlot>} 放送枠 */ async getByChannelId(channelId) { let slots = await this.getAll() let slot = slots.find(v => v.isOnair && v.channelId === channelId) // 見つからなかった場合、最新の放送枠を取得して探す if (slot === undefined) { slots = await this.fetch() slot = slots.find(v => v.isOnair && v.channelId === channelId) } return slot }, /**************************************** * 現在再生中の放送枠を取得 * @returns {Promise<AbmSlot>} 放送枠 */ async getNow() { const nowSlot = await this.getByChannelId(this.getNowChannelId()) if (nowSlot === undefined) { throw new Error('nowSlot is undefined') } return nowSlot }, /**************************************** * 現在再生中のチャンネルIDを取得 * @returns {string} チャンネルID */ getNowChannelId() { let url = window.location.href if (url === 'https://abema.tv/' && ABMCMT.temp.video !== null) { const preview = ABMCMT.temp.video.closest('.com-TvAreaOfPreviewLayout__content') const link = preview?.getElementsByClassName('com-a-Link')[0] url = link instanceof HTMLAnchorElement ? link.href : undefined } if (typeof url === 'string' && url.startsWith('https://abema.tv/now-on-air/')) { return url.match(/https:\/\/abema\.tv\/now-on-air\/(\S+)/)[1] } else if (url === undefined) { throw new Error('url is undefined') } else { throw new Error('unknown error') } }, }, /**************************************** * コメント関連 */ comment: { /**************************************** * コメントを取得 * @param {string} slotId 放送枠のID * @param {number} [since] `since`以降のコメントを取得 * @param {number} [limit] 取得するコメント数上限 * @returns {Promise<Array<AbmComment>>} コメント一覧 */ async fetch(slotId, since, limit) { if (slotId === undefined) { throw new Error('slotId is undefined') } const res = await fetch( ABMCMT.config.api.comments(slotId, since, limit), ABMCMT.config.requestInit ) const { comments } = await res.json() if (Array.isArray(comments)) { return comments.map(v => new AbmComment(v)) } else { return [] } }, /**************************************** * 新着コメントを取得 * @param {number} [limit] 取得するコメント数上限 * @returns {Promise<Array<AbmComment>>} コメント一覧 */ async fetchNewComments(limit) { const nowSlot = await ABMCMT.util.slot.getNow() const since = ABMCMT.temp.latestCreatedAtMs const comments = await this.fetch( nowSlot.id, Number.isFinite(since) ? (since + 1) : null, limit ) if (comments.length !== 0) { ABMCMT.temp.latestCreatedAtMs = comments[0].createdAtMs } return comments }, }, } /**************************************** * 初期化 * @param {HTMLElement} [video] Video要素 */ ABMCMT.initialize = async function(video) { // キャッシュをリセット this.temp.reset() if (video !== undefined) { this.temp.video = video if (document.getElementsByClassName('mid-FlowCommentsArea').length === 0) { // FlowCommentsのCanvas追加 this.temp.video.insertAdjacentElement('afterend', fc.canvas) } } // チャンネル情報一覧を取得 await this.util.slot.fetch() // 一旦最新のコメントを取得しておく await this.util.comment.fetchNewComments(1) // 最新コメントの投稿日時を数秒前に戻しておく(初回読み込み時に流すため) this.temp.latestCreatedAtMs -= this.config.INTERVAL_TIME } /**************************************** * 定期的に実行するやつ */ ABMCMT.scheduler = { /** @type {number} */ _id: null, /**************************************** * ループ中に実行される処理 * @return {Promise} */ async update() { try { const comments = await ABMCMT.util.comment.fetchNewComments() if (0 < comments.length) { if (1 < comments.length) { const latest = comments[0].createdAtMs const oldest = comments.slice(-1)[0].createdAtMs const time_mul = (ABMCMT.config.INTERVAL_TIME - 2000) / (latest - oldest) comments.forEach(val => { const timeout = (val.createdAtMs - oldest) * time_mul setTimeout((id) => { if (id === this._id) { fc.pushComment(new FlowCommentItem(val.id, val.message)) } }, timeout, this._id) }) } else { fc.pushComment(new FlowCommentItem(comments[0].id, comments[0].message)) } } } catch (e) { console.error(e) } }, /**************************************** * 開始 */ start() { if (this._id === null) { fc.start() this.update() this._id = window.setInterval(async () => { this.update() }, ABMCMT.config.INTERVAL_TIME) } }, /**************************************** * 停止 */ stop() { if (this._id !== null) { fc.initialize() window.clearInterval(this._id) this._id = null } }, } // @ts-ignore unsafeWindow.ABMCMT = ABMCMT //---------------------------------------- // .com-a-Video__video を取得する //---------------------------------------- const obs_target = document.body const obs_options = { childList: true, subtree: true } const obs = new MutationObserver(async mutationsList => { obs.disconnect() for (const { addedNodes, removedNodes } of mutationsList) { // 追加 for (const added of addedNodes) { if (!(added instanceof HTMLElement)) continue if (added.classList.contains('com-a-Video__video')) { try { await ABMCMT.initialize(added) ABMCMT.scheduler.start() } catch (e) { console.error(e) } } } // 削除 for (const removed of removedNodes) { if (!(removed instanceof HTMLElement)) continue if (removed === ABMCMT.temp.video) { ABMCMT.scheduler.stop() } } } obs.observe(obs_target, obs_options) }) obs.observe(obs_target, obs_options) //---------------------------------------- // CSS //---------------------------------------- const style = ` .mid-FlowComments { position: absolute; width: 100%; height: 100%; pointer-events: none; } ` document.head.insertAdjacentHTML('beforeend', `<style id="mid-FlowComments-style">${style}</style>` ) }