From 582c1606c10731144f78e9fd8f93c2122325f3f5 Mon Sep 17 00:00:00 2001 From: "55448286+taizan-hokuto@users.noreply.github.com" Date: Sun, 3 Nov 2019 08:49:05 +0900 Subject: [PATCH] Created base files --- MANIFEST.in | 1 + README.md | 120 ++ pytchat/__init__.py | 19 + pytchat/api.py | 7 + pytchat/config/__init__.py | 4 + pytchat/core_async/__init__.py | 0 pytchat/core_async/buffer.py | 28 + pytchat/core_async/listen_manager.py | 184 ++ pytchat/core_async/livechat.py | 299 +++ pytchat/core_async/parser.py | 40 + pytchat/core_multithread/__init__.py | 0 pytchat/core_multithread/buffer.py | 31 + pytchat/core_multithread/livechat.py | 277 +++ pytchat/core_multithread/parser.py | 39 + pytchat/exceptions.py | 43 + pytchat/mylogger.py | 31 + pytchat/paramgen/__init__.py | 0 pytchat/paramgen/liveparam.py | 143 ++ pytchat/processors/__init__.py | 0 pytchat/processors/chat_processor.py | 27 + pytchat/processors/compatible/__init__.py | 0 pytchat/processors/compatible/parser.py | 43 + pytchat/processors/compatible/processor.py | 39 + .../compatible/renderer/__init__.py | 0 .../processors/compatible/renderer/base.py | 83 + .../compatible/renderer/currency.py | 37 + .../compatible/renderer/legacypaid.py | 43 + .../compatible/renderer/paidmessage.py | 41 + .../compatible/renderer/paidsticker.py | 48 + .../compatible/renderer/textmessage.py | 4 + pytchat/processors/default/__init__.py | 0 pytchat/processors/default/parser.py | 39 + pytchat/processors/default/processor.py | 44 + .../processors/default/renderer/__init__.py | 0 pytchat/processors/default/renderer/base.py | 80 + .../processors/default/renderer/currency.py | 37 + .../processors/default/renderer/legacypaid.py | 18 + .../default/renderer/paidmessage.py | 36 + .../default/renderer/paidsticker.py | 13 + .../default/renderer/textmessage.py | 4 + pytchat/processors/json_display_processor.py | 13 + .../processors/jsonfile_archive_processor.py | 46 + .../processors/simple_display_processor.py | 61 + pytchat/util/__init__.py | 15 + requirements.txt | 9 + setup.py | 56 + tests/__init__.py | 0 tests/test_compatible_processor.py | 128 ++ tests/test_livechat.py | 53 + tests/test_parser.py | 44 + tests/testdata/compatible/API_NewSponsor.json | 35 + tests/testdata/compatible/API_SuperChat.json | 42 + .../testdata/compatible/API_TextMessage.json | 38 + tests/testdata/compatible/newSponsor.json | 1857 +++++++++++++++++ tests/testdata/compatible/superchat.json | 282 +++ tests/testdata/compatible/supersticker.json | 197 ++ tests/testdata/compatible/textmessage.json | 177 ++ tests/testdata/finished_live.json | 1 + tests/testdata/paramgen_firstread.json | 1 + 59 files changed, 4957 insertions(+) create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 pytchat/__init__.py create mode 100644 pytchat/api.py create mode 100644 pytchat/config/__init__.py create mode 100644 pytchat/core_async/__init__.py create mode 100644 pytchat/core_async/buffer.py create mode 100644 pytchat/core_async/listen_manager.py create mode 100644 pytchat/core_async/livechat.py create mode 100644 pytchat/core_async/parser.py create mode 100644 pytchat/core_multithread/__init__.py create mode 100644 pytchat/core_multithread/buffer.py create mode 100644 pytchat/core_multithread/livechat.py create mode 100644 pytchat/core_multithread/parser.py create mode 100644 pytchat/exceptions.py create mode 100644 pytchat/mylogger.py create mode 100644 pytchat/paramgen/__init__.py create mode 100644 pytchat/paramgen/liveparam.py create mode 100644 pytchat/processors/__init__.py create mode 100644 pytchat/processors/chat_processor.py create mode 100644 pytchat/processors/compatible/__init__.py create mode 100644 pytchat/processors/compatible/parser.py create mode 100644 pytchat/processors/compatible/processor.py create mode 100644 pytchat/processors/compatible/renderer/__init__.py create mode 100644 pytchat/processors/compatible/renderer/base.py create mode 100644 pytchat/processors/compatible/renderer/currency.py create mode 100644 pytchat/processors/compatible/renderer/legacypaid.py create mode 100644 pytchat/processors/compatible/renderer/paidmessage.py create mode 100644 pytchat/processors/compatible/renderer/paidsticker.py create mode 100644 pytchat/processors/compatible/renderer/textmessage.py create mode 100644 pytchat/processors/default/__init__.py create mode 100644 pytchat/processors/default/parser.py create mode 100644 pytchat/processors/default/processor.py create mode 100644 pytchat/processors/default/renderer/__init__.py create mode 100644 pytchat/processors/default/renderer/base.py create mode 100644 pytchat/processors/default/renderer/currency.py create mode 100644 pytchat/processors/default/renderer/legacypaid.py create mode 100644 pytchat/processors/default/renderer/paidmessage.py create mode 100644 pytchat/processors/default/renderer/paidsticker.py create mode 100644 pytchat/processors/default/renderer/textmessage.py create mode 100644 pytchat/processors/json_display_processor.py create mode 100644 pytchat/processors/jsonfile_archive_processor.py create mode 100644 pytchat/processors/simple_display_processor.py create mode 100644 pytchat/util/__init__.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_compatible_processor.py create mode 100644 tests/test_livechat.py create mode 100644 tests/test_parser.py create mode 100644 tests/testdata/compatible/API_NewSponsor.json create mode 100644 tests/testdata/compatible/API_SuperChat.json create mode 100644 tests/testdata/compatible/API_TextMessage.json create mode 100644 tests/testdata/compatible/newSponsor.json create mode 100644 tests/testdata/compatible/superchat.json create mode 100644 tests/testdata/compatible/supersticker.json create mode 100644 tests/testdata/compatible/textmessage.json create mode 100644 tests/testdata/finished_live.json create mode 100644 tests/testdata/paramgen_firstread.json diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f9bd145 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..d45e4ec --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ + +pytchat +======= + +pytchat is a python library for fetching youtube live chat. + +## Description +pytchat is a python library for fetching youtube live chat. +without using youtube api, Selenium or BeautifulSoup. + +Other features: ++ Customizable chat data processors including yt api compatible one. ++ Available on asyncio context. ++ Quick fetching of initial chat data by generating continuation params +instead of web scraping. + +## Install +``` +pip install pytchat +``` + +## Examples +``` +from pytchat import LiveChat + +chat = LiveChat("G1w62uEMZ74") +while chat.is_alive(): + data = chat.get() + for c in data.items: + print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}") + data.tick() +``` + +callback mode +``` +from pytchat import LiveChat + +chat = LiveChat("G1w62uEMZ74", callback = func) +while chat.is_alive(): + time.sleep(3) + +def func(chatdata): + for c in chatdata.items: + print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}") + chat.tick() +``` + +asyncio context: +``` +from pytchat import LiveChatAsync +import asyncio + +async def main(): + chat = LiveChatAsync("G1w62uEMZ74", callback = func) + while chat.is_alive(): + #other background operation here. + await asyncio.sleep(3) + +async def func(chat) + for c in chat.items: + print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}") + await chat.tick_async() + +loop = asyncio.get_event_loop() +loop.run_until_complete(main()) +``` + + +yt api compatible processor: +``` +from pytchat import LiveChat, CompatibleProcessor + +chat = LiveChat("G1w62uEMZ74", + processor = CompatibleProcessor() ) + +while chat.is_alive(): + data = chat.get() + polling = data["pollingIntervalMillis"]/1000 + for c in data["items"]: + if c.get("snippet"): + print(f"[{c['authorDetails']['displayName']}]" + f"-{c['snippet']['displayMessage']}") + time.sleep(polling/len(data["items"])) + +``` + + +## Chatdata Structure of Default Processor +Structure of each item which got from items() function. +|name|type|remarks| +|:----|:----|:----| +|type|str|"superChat","textMessage","superSticker","newSponsor"| +|id|str|| +|message|str|emojis are represented by ":(shortcut text):"| +|datetime|str|YYYY-mm-dd HH:MM:SS format| +|timestamp|int|unixtime milliseconds| +|amountValue|float|ex. 1,234.0| +|amountString|str|ex. "$ 1,234"| +|currency|str|ex. "USD"| +|author|object|see below| + +Structure of author object. +|name|type|remarks| +|:----|:----|:----| +|name|str|| +|channelId|str|authorExternalChannelId| +|channelUrl|str|| +|imageUrl|str|| +|badgeUrl|str|| +|isVerified|bool|| +|isChatOwner|bool|| +|isChatSponsor|bool|| +|isChatModerator|bool|| +## Licence + +[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) + +## Author + +[taizan-hokuto](https://github.com/taizan-hokuto) \ No newline at end of file diff --git a/pytchat/__init__.py b/pytchat/__init__.py new file mode 100644 index 0000000..d281587 --- /dev/null +++ b/pytchat/__init__.py @@ -0,0 +1,19 @@ +""" +pytchat is a python library for fetching youtube live chat. +""" +__copyright__ = 'Copyright (C) 2019 taizan-hokuto' +__version__ = '0.0.1.4' +__license__ = 'MIT' +__author__ = 'taizan-hokuto' +__author_email__ = '55448286+taizan-hokuto@users.noreply.github.com' +__url__ = 'https://github.com/taizan-hokuto' + +__all__ = ["core_async","core_multithread","processors"] + +from .api import ( + LiveChat, + LiveChatAsync, + CompatibleProcessor, + SimpleDisplayProcessor, + JsonfileArchiveProcessor + ) \ No newline at end of file diff --git a/pytchat/api.py b/pytchat/api.py new file mode 100644 index 0000000..beeec65 --- /dev/null +++ b/pytchat/api.py @@ -0,0 +1,7 @@ +from .core_async.livechat import LiveChatAsync +from .core_multithread.livechat import LiveChat +from .processors.default.processor import DefaultProcessor +from .processors.compatible.processor import CompatibleProcessor +from .processors.simple_display_processor import SimpleDisplayProcessor +from .processors.jsonfile_archive_processor import JsonfileArchiveProcessor + diff --git a/pytchat/config/__init__.py b/pytchat/config/__init__.py new file mode 100644 index 0000000..b17df93 --- /dev/null +++ b/pytchat/config/__init__.py @@ -0,0 +1,4 @@ +import logging +LOGGER_MODE = logging.ERROR +headers = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36'} diff --git a/pytchat/core_async/__init__.py b/pytchat/core_async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/core_async/buffer.py b/pytchat/core_async/buffer.py new file mode 100644 index 0000000..7510f19 --- /dev/null +++ b/pytchat/core_async/buffer.py @@ -0,0 +1,28 @@ + +import asyncio +class Buffer(asyncio.Queue): + ''' + チャットデータを格納するバッファの役割を持つLIFOキュー + + Parameter + --------- + maxsize : int + 格納するチャットブロックの最大個数。0の場合は無限。 + 最大値を超える場合は古いチャットブロックから破棄される。 + ''' + def __init__(self,maxsize = 0): + super().__init__(maxsize) + + async def put(self,item): + if item is None: + return + if super().full(): + super().get_nowait() + await super().put(item) + + async def get(self): + ret = [] + ret.append(await super().get()) + while not super().empty(): + ret.append(super().get_nowait()) + return ret \ No newline at end of file diff --git a/pytchat/core_async/listen_manager.py b/pytchat/core_async/listen_manager.py new file mode 100644 index 0000000..c35e6bd --- /dev/null +++ b/pytchat/core_async/listen_manager.py @@ -0,0 +1,184 @@ +import asyncio +from .listener import AsyncListener +from .. import config +from .. import mylogger +import datetime +import os +import aiohttp +import signal +import threading +from .buffer import Buffer +from concurrent.futures import CancelledError +logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE) + +class ListenManager: + + ''' + 動画IDまたは動画IDのリストを受け取り、 + 動画IDに対応したListenerを生成・保持する。 + + #Attributes + ---------- + _listeners: dict + ListenManegerがつかんでいるListener達のリスト. + key:動画ID value:動画IDに対応するListener + _queue: Queue + 動画IDを外部から受け渡しするためのキュー + _queueが空である間は、ノンブロッキングで他のタスクを実行 + _queueに動画IDが投入されると、_dequeueメソッドで + 直ちにListenerを生成し返す。 + _event: threading.Event + キーボードのCTRL+Cを検知するためのEventオブジェクト + ''' + def __init__(self,interruptable = True): + #チャット監視中の動画リスト + self._listeners={} + self._tasks = [] + #外部からvideoを受け取るためのキュー + self._queue = asyncio.Queue() + self._event = threading.Event() + self._ready_queue() + self._is_alive = True + #キーボードのCtrl+cを押したとき、_hundler関数を呼び出すように設定 + signal.signal(signal.SIGINT, (lambda a, b: self._handler(self._event, a, b))) + + def is_alive(self)->bool: + ''' + ListenManagerが稼働中であるか。 + True->稼働中 + False->Ctrl+Cが押されて終了 + ''' + logger.debug(f'check is_alive() :{self._is_alive}') + return self._is_alive + + def _handler(self, event, sig, handler): + ''' + Ctrl+Cが押下されたとき、終了フラグをセットする。 + ''' + logger.debug('Ctrl+c pushed') + self._is_alive = False + logger.debug('terminating listeners.') + for listener in self._listeners.values(): + listener.terminate() + logger.debug('end.') + + + def _ready_queue(self): + #loop = asyncio.get_event_loop() + self._tasks.append( + asyncio.create_task(self._dequeue()) + ) + + + async def set_video_ids(self,video_ids:list): + for video_id in video_ids: + if video_id: + await self._queue.put(video_id) + + + async def get_listener(self,video_id) -> AsyncListener: + return await self._create_listener(video_id) + + # async def getlivechat(self,video_id): + # ''' + # 指定された動画IDのチャットデータを返す + + # Parameter + # ---------- + # video_id: str + # 動画ID + + # Return + # ---------- + # 引数で受け取った動画IDに対応する + # Listenerオブジェクトへの参照 + + # ''' + # logger.debug('manager get/create listener') + # listener = await self._create_listener(video_id) + # ''' + # 上が完了しないうちに、下が呼び出される + # ''' + # if not listener._initialized: + # await asyncio.sleep(2) + # return [] + # if listener: + # #listener._isfirstrun=False + # return await listener.getlivechat() + + + + async def _dequeue(self): + ''' + キューに入った動画IDを + Listener登録に回す。 + + ''' + while True: + video_id = await self._queue.get() + #listenerを登録、タスクとして実行する + logger.debug(f'deque got [{video_id}]') + await self._create_listener(video_id) + + async def _create_listener(self, video_id) -> AsyncListener: + ''' + Listenerを作成しチャット取得中リストに加え、 + Listenerを返す + ''' + if video_id is None or not isinstance(video_id, str): + raise TypeError('video_idは文字列でなければなりません') + if video_id in self._listeners: + return self._listeners[video_id] + else: + #listenerを登録する + listener = AsyncListener(video_id,interruptable = False,buffer = Buffer()) + self._listeners.setdefault(video_id,listener) + #task = asyncio.ensure_future(listener.initialize()) + #await asyncio.gather(listener.initialize()) + #task.add_done_callback(self.finish) + #await listener.initialize() + #self._tasks.append(task) + + return listener + + + def finish(self,sender): + try: + if sender.result(): + video_id = sender.result()[0] + message = sender.result()[1] + + #listener終了時のコールバック + #sender.result()[]でデータを取得できる + logger.info(f'終了しました VIDEO_ID:[{video_id}] message:{message}') + #logger.info(f'終了しました') + if video_id in self._listeners: + self._listeners.pop(video_id) + except CancelledError: + logger.debug('cancelled.') + + def get_listeners(self): + return self._listeners + + def shutdown(self): + ''' + ListenManegerを終了する + ''' + logger.debug("start shutdown") + self._is_alive =False + try: + #Listenerを停止する。 + for listener in self._listeners.values(): + listener.terminate() + #taskをキャンセルする。 + for task in self._tasks: + if not task.done(): + #print(task) + task.cancel() + except Exception as er: + logger.info(str(er),type(er)) + + logger.debug("finished.") + + def get_tasks(self): + return self._tasks \ No newline at end of file diff --git a/pytchat/core_async/livechat.py b/pytchat/core_async/livechat.py new file mode 100644 index 0000000..93d9fcc --- /dev/null +++ b/pytchat/core_async/livechat.py @@ -0,0 +1,299 @@ +import aiohttp, asyncio, async_timeout +import datetime +import json +import random +import signal +import threading +import time +import traceback +import urllib.parse +from aiohttp.client_exceptions import ClientConnectorError +from concurrent.futures import CancelledError +from .buffer import Buffer +from .parser import Parser +from .. import config +from .. import mylogger +from ..exceptions import ChatParseException,IllegalFunctionCall +from ..paramgen import liveparam + +from ..processors.default.processor import DefaultProcessor + +logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE) +MAX_RETRY = 10 +headers = config.headers + + + +class LiveChatAsync: + '''asyncio(aiohttp)を利用してYouTubeのライブ配信のチャットデータを取得する。 + + Parameter + --------- + video_id : str + 動画ID + + processor : ChatProcessor + チャットデータを加工するオブジェクト + + buffer : Buffer(maxsize:20[default]) + チャットデータchat_componentを格納するバッファ。 + maxsize : 格納できるchat_componentの個数 + default値20個。1個で約5~10秒分。 + + interruptable : bool + Ctrl+Cによる処理中断を行うかどうか。 + + callback : func + _listen()関数から一定間隔で自動的に呼びだす関数。 + + done_callback : func + listener終了時に呼び出すコールバック。 + + exception_handler : func + 例外を処理する関数 + + direct_mode : bool + Trueの場合、bufferを使わずにcallbackを呼ぶ。 + Trueの場合、callbackの設定が必須 + (設定していない場合IllegalFunctionCall例外を発生させる) + + Attributes + --------- + _is_alive : bool + チャット取得を停止するためのフラグ + ''' + + _setup_finished = False + + def __init__(self, video_id, + processor = DefaultProcessor(), + buffer = None, + interruptable = True, + callback = None, + done_callback = None, + exception_handler = None, + direct_mode = False): + self.video_id = video_id + self.processor = processor + self._buffer = buffer + self._callback = callback + self._done_callback = done_callback + self._exception_handler = exception_handler + self._direct_mode = direct_mode + self._is_alive = True + + self._setup() + + if not LiveChatAsync._setup_finished: + LiveChatAsync._setup_finished = True + if exception_handler == None: + self._set_exception_handler(self._handle_exception) + else: + self._set_exception_handler(exception_handler) + if interruptable: + signal.signal(signal.SIGINT, + (lambda a, b:asyncio.create_task( + LiveChatAsync.shutdown(None,signal.SIGINT,b)) + )) + + def _setup(self): + #direct modeがTrueでcallback未設定の場合例外発生。 + if self._direct_mode: + if self._callback is None: + raise IllegalFunctionCall( + "direct_mode=Trueの場合callbackの設定が必須です。") + else: + #direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成 + if self._buffer is None: + self._buffer = Buffer(maxsize = 20) + #callbackが指定されている場合はcallbackを呼ぶループタスクを作成 + if self._callback is None: + pass + else: + #callbackを呼ぶループタスクの開始 + loop = asyncio.get_event_loop() + loop.create_task(self._callback_loop(self._callback)) + #_listenループタスクの開始 + loop = asyncio.get_event_loop() + listen_task = loop.create_task(self._startlisten()) + #add_done_callbackの登録 + if self._done_callback is None: + listen_task.add_done_callback(self.finish) + else: + listen_task.add_done_callback(self._done_callback) + + async def _startlisten(self): + """最初のcontinuationパラメータを取得し、 + _listenループを開始する + """ + initial_continuation = await self._get_initial_continuation() + if initial_continuation is None: + self.terminate() + logger.debug(f"[{self.video_id}]No initial continuation.") + return + await self._listen(initial_continuation) + + async def _get_initial_continuation(self): + ''' チャットデータ取得に必要な最初のcontinuationを取得する。''' + try: + initial_continuation = liveparam.getparam(self.video_id) + except ChatParseException as e: + self.terminate() + logger.debug(f"[{self.video_id}]Error:{str(e)}") + return + except KeyError: + logger.debug(f"[{self.video_id}]KeyError:" + f"{traceback.format_exc(limit = -1)}") + self.terminate() + return + return initial_continuation + + async def _listen(self, continuation): + ''' continuationに紐付いたチャットデータを取得し + チャットデータを格納、 + 次のcontinuaitonを取得してループする。 + + Parameter + --------- + continuation : str + 次のチャットデータ取得に必要なパラメータ + ''' + try: + async with aiohttp.ClientSession() as session: + while(continuation and self._is_alive): + livechat_json = (await + self._get_livechat_json(continuation, session, headers) + ) + metadata, chatdata = Parser.parse( livechat_json ) + timeout = metadata['timeoutMs']/1000 + chat_component = { + "video_id" : self.video_id, + "timeout" : timeout, + "chatdata" : chatdata + } + time_mark =time.time() + if self._direct_mode: + await self._callback( + self.processor.process([chat_component]) + ) + else: + await self._buffer.put(chat_component) + diff_time = timeout - (time.time()-time_mark) + await asyncio.sleep(diff_time) + continuation = metadata.get('continuation') + except ChatParseException as e: + logger.info(f"{str(e)}(video_id:\"{self.video_id}\")") + return + except (TypeError , json.JSONDecodeError) : + logger.error(f"{traceback.format_exc(limit = -1)}") + return + + logger.debug(f"[{self.video_id}]チャット取得を終了しました。") + + async def _get_livechat_json(self, continuation, session, headers): + ''' + チャットデータが格納されたjsonデータを取得する。 + ''' + continuation = urllib.parse.quote(continuation) + livechat_json = None + status_code = 0 + url =( + f"https://www.youtube.com/live_chat/get_live_chat?" + f"continuation={continuation}&pbj=1") + for _ in range(MAX_RETRY + 1): + async with session.get(url ,headers = headers) as resp: + try: + text = await resp.text() + status_code = resp.status + livechat_json = json.loads(text) + break + except (ClientConnectorError,json.JSONDecodeError) : + await asyncio.sleep(1) + continue + else: + logger.error(f"[{self.video_id}]" + f"Exceeded retry count. status_code={status_code}") + return None + return livechat_json + + async def _callback_loop(self,callback): + """ コンストラクタでcallbackを指定している場合、バックグラウンドで + callbackに指定された関数に一定間隔でチャットデータを投げる。 + + Parameter + --------- + callback : func + 加工済みのチャットデータを渡す先の関数。 + """ + while self.is_alive(): + items = await self._buffer.get() + data = self.processor.process(items) + await callback(data) + + async def get(self): + """ bufferからデータを取り出し、processorに投げ、 + 加工済みのチャットデータを返す。 + + Returns + : Processorによって加工されたチャットデータ + """ + if self._callback is None: + items = await self._buffer.get() + return self.processor.process(items) + raise IllegalFunctionCall( + "既にcallbackを登録済みのため、get()は実行できません。") + + def is_alive(self): + return self._is_alive + + def finish(self,sender): + '''Listener終了時のコールバック''' + try: + self.terminate() + except CancelledError: + logger.debug(f'[{self.video_id}]cancelled:{sender}') + + def terminate(self): + ''' + Listenerを終了する。 + ''' + self._is_alive = False + if self._direct_mode == False: + #bufferにダミーオブジェクトを入れてis_alive()を判定させる + self._buffer.put_nowait({'chatdata':'','timeout':1}) + logger.info(f'終了しました:[{self.video_id}]') + + @classmethod + def _set_exception_handler(cls, handler): + loop = asyncio.get_event_loop() + #default handler: cls._handle_exception + loop.set_exception_handler(handler) + + @classmethod + def _handle_exception(cls, loop, context): + #msg = context.get("exception", context["message"]) + if not isinstance(context["exception"],CancelledError): + logger.error(f"Caught exception: {context}") + loop= asyncio.get_event_loop() + loop.create_task(cls.shutdown(None,None,None)) + + @classmethod + async def shutdown(cls, event, sig = None, handler=None): + logger.debug("シャットダウンしています") + tasks = [t for t in asyncio.all_tasks() if t is not + asyncio.current_task()] + [task.cancel() for task in tasks] + + logger.debug(f"残っているタスクを終了しています") + await asyncio.gather(*tasks,return_exceptions=True) + loop = asyncio.get_event_loop() + loop.stop() + + + + + + + + + diff --git a/pytchat/core_async/parser.py b/pytchat/core_async/parser.py new file mode 100644 index 0000000..16a7412 --- /dev/null +++ b/pytchat/core_async/parser.py @@ -0,0 +1,40 @@ +import json +from .. import config +from .. import mylogger +from .. exceptions import ( + ResponseContextError, + NoContentsException, + NoContinuationsException ) + + +logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE) + + +class Parser: + @classmethod + def parse(cls, jsn): + if jsn is None: + return {'timeoutMs':0,'continuation':None},[] + if jsn['response']['responseContext'].get('errors'): + raise ResponseContextError('動画に接続できません。' + '動画IDが間違っているか、動画が削除/非公開の可能性があります。') + contents=jsn['response'].get('continuationContents') + #配信が終了した場合、もしくはチャットデータが取得できない場合 + if contents is None: + raise NoContentsException('チャットデータを取得できませんでした。') + + cont = contents['liveChatContinuation']['continuations'][0] + if cont is None: + raise NoContinuationsException('Continuationがありません。') + metadata = (cont.get('invalidationContinuationData') or + cont.get('timedContinuationData') or + cont.get('reloadContinuationData') + ) + if metadata is None: + unknown = list(cont.keys())[0] + if unknown: + logger.error(f"Received unknown continuation type:{unknown}") + metadata = cont.get(unknown) + metadata.setdefault('timeoutMs', 10000) + chatdata = contents['liveChatContinuation'].get('actions') + return metadata, chatdata diff --git a/pytchat/core_multithread/__init__.py b/pytchat/core_multithread/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/core_multithread/buffer.py b/pytchat/core_multithread/buffer.py new file mode 100644 index 0000000..1178839 --- /dev/null +++ b/pytchat/core_multithread/buffer.py @@ -0,0 +1,31 @@ + +import queue + +class Buffer(queue.Queue): + ''' + チャットデータを格納するバッファの役割を持つLIFOキュー + + Parameter + --------- + max_size : int + 格納するチャットブロックの最大個数。0の場合は無限。 + 最大値を超える場合は古いチャットブロックから破棄される。 + ''' + def __init__(self,maxsize = 0): + super().__init__(maxsize=maxsize) + + def put(self,item): + if item is None: + return + if super().full(): + super().get_nowait() + else: + super().put(item) + + + def get(self): + ret = [] + ret.append(super().get()) + while not super().empty(): + ret.append(super().get()) + return ret \ No newline at end of file diff --git a/pytchat/core_multithread/livechat.py b/pytchat/core_multithread/livechat.py new file mode 100644 index 0000000..0e4e076 --- /dev/null +++ b/pytchat/core_multithread/livechat.py @@ -0,0 +1,277 @@ +import requests +import datetime +import json +import random +import signal +import threading +import time +import traceback +import urllib.parse +from concurrent.futures import CancelledError, ThreadPoolExecutor +from .buffer import Buffer +from .parser import Parser +from .. import config +from .. import mylogger +from ..exceptions import ChatParseException,IllegalFunctionCall +from ..paramgen import liveparam +from ..processors.default.processor import DefaultProcessor + +logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE) +MAX_RETRY = 10 +headers = config.headers + + + +class LiveChat: + ''' スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する + + Parameter + --------- + video_id : str + 動画ID + + processor : ChatProcessor + チャットデータを加工するオブジェクト + + buffer : Buffer(maxsize:20[default]) + チャットデータchat_componentを格納するバッファ。 + maxsize : 格納できるchat_componentの個数 + default値20個。1個で約5~10秒分。 + + interruptable : bool + Ctrl+Cによる処理中断を行うかどうか。 + + callback : func + _listen()関数から一定間隔で自動的に呼びだす関数。 + + done_callback : func + listener終了時に呼び出すコールバック。 + + direct_mode : bool + Trueの場合、bufferを使わずにcallbackを呼ぶ。 + Trueの場合、callbackの設定が必須 + (設定していない場合IllegalFunctionCall例外を発生させる) + + Attributes + --------- + _executor : ThreadPoolExecutor + チャットデータ取得ループ(_listen)用のスレッド + + _is_alive : bool + チャット取得を終了したか + ''' + + _setup_finished = False + #チャット監視中のListenerのリスト + _listeners= [] + def __init__(self, video_id, + processor = DefaultProcessor(), + buffer = Buffer(maxsize = 20), + interruptable = True, + callback = None, + done_callback = None, + direct_mode = False + ): + self.video_id = video_id + self.processor = processor + self._buffer = buffer + self._callback = callback + self._done_callback = done_callback + self._executor = ThreadPoolExecutor(max_workers=2) + self._direct_mode = direct_mode + self._is_alive = True + self._parser = Parser() + self._setup() + + if not LiveChat._setup_finished: + LiveChat._setup_finished = True + if interruptable: + signal.signal(signal.SIGINT, (lambda a, b: + (LiveChat.shutdown(None,signal.SIGINT,b)) + )) + LiveChat._listeners.append(self) + + def _setup(self): + #direct modeがTrueでcallback未設定の場合例外発生。 + if self._direct_mode: + if self._callback is None: + raise IllegalFunctionCall( + "direct_mode=Trueの場合callbackの設定が必須です。") + else: + #direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成 + if self._buffer is None: + self._buffer = Buffer(maxsize = 20) + #callbackが指定されている場合はcallbackを呼ぶループタスクを作成 + if self._callback is None: + pass + else: + #callbackを呼ぶループタスクの開始 + self._executor.submit(self._callback_loop,self._callback) + #_listenループタスクの開始 + listen_task = self._executor.submit(self._startlisten) + #add_done_callbackの登録 + if self._done_callback is None: + listen_task.add_done_callback(self.finish) + else: + listen_task.add_done_callback(self._done_callback) + + def _startlisten(self): + """最初のcontinuationパラメータを取得し、 + _listenループのタスクを作成し開始する + """ + initial_continuation = self._get_initial_continuation() + if initial_continuation is None: + self.terminate() + logger.debug(f"[{self.video_id}]No initial continuation.") + return + self._listen(initial_continuation) + + def _get_initial_continuation(self): + ''' チャットデータ取得に必要な最初のcontinuationを取得する。''' + try: + initial_continuation = liveparam.getparam(self.video_id) + except ChatParseException as e: + self.terminate() + logger.debug(f"[{self.video_id}]Error:{str(e)}") + return + except KeyError: + logger.debug(f"[{self.video_id}]KeyError:" + f"{traceback.format_exc(limit = -1)}") + self.terminate() + return + return initial_continuation + + def _listen(self, continuation): + ''' continuationに紐付いたチャットデータを取得し + にチャットデータを格納、 + 次のcontinuaitonを取得してループする + + Parameter + --------- + continuation : str + 次のチャットデータ取得に必要なパラメータ + ''' + try: + with requests.Session() as session: + while(continuation and self._is_alive): + livechat_json = ( + self._get_livechat_json(continuation, session, headers) + ) + metadata, chatdata = self._parser.parse( livechat_json ) + #チャットデータを含むコンポーネントを組み立ててbufferに投入する + timeout = metadata['timeoutMs']/1000 + chat_component = { + "video_id" : self.video_id, + "timeout" : timeout, + "chatdata" : chatdata + } + time_mark =time.time() + if self._direct_mode: + self._callback( + self.processor.process([chat_component]) + ) + else: + self._buffer.put(chat_component) + #次のchatを取得するまでsleepする + diff_time = timeout - (time.time()-time_mark) + if diff_time < 0 : diff_time=0 + time.sleep(diff_time) + #次のチャットデータのcontinuationパラメータを取り出す。 + continuation = metadata.get('continuation') + + #whileループ先頭に戻る + except ChatParseException as e: + logger.error(f"{str(e)}(動画ID:\"{self.video_id}\")") + return + except (TypeError , json.JSONDecodeError) : + logger.error(f"{traceback.format_exc(limit = -1)}") + return + + logger.debug(f"[{self.video_id}]チャット取得を終了しました。") + + def _get_livechat_json(self, continuation, session, headers): + ''' + チャットデータが格納されたjsonデータを取得する。 + ''' + continuation = urllib.parse.quote(continuation) + livechat_json = None + status_code = 0 + url =( + f"https://www.youtube.com/live_chat/get_live_chat?" + f"continuation={continuation}&pbj=1") + for _ in range(MAX_RETRY + 1): + with session.get(url ,headers = headers) as resp: + try: + text = resp.text + status_code = resp.status_code + livechat_json = json.loads(text) + break + except json.JSONDecodeError : + time.sleep(1) + continue + else: + logger.error(f"[{self.video_id}]" + f"Exceeded retry count. status_code={status_code}") + return None + return livechat_json + + def _callback_loop(self,callback): + """ コンストラクタでcallbackを指定している場合、バックグラウンドで + callbackに指定された関数に一定間隔でチャットデータを投げる。 + + Parameter + --------- + callback : func + 加工済みのチャットデータを渡す先の関数。 + """ + while self.is_alive(): + items = self._buffer.get() + data = self.processor.process(items) + callback(data) + + def get(self): + """ bufferからデータを取り出し、processorに投げ、 + 加工済みのチャットデータを返す。 + + Returns + : Processorによって加工されたチャットデータ + """ + if self._callback is None: + items = self._buffer.get() + return self.processor.process(items) + raise IllegalFunctionCall( + "既にcallbackを登録済みのため、get()は実行できません。") + + def is_alive(self): + return self._is_alive + + def finish(self,sender): + '''Listener終了時のコールバック''' + try: + self.terminate() + except CancelledError: + logger.debug(f'[{self.video_id}]cancelled:{sender}') + + def terminate(self): + ''' + Listenerを終了する。 + ''' + self._is_alive = False + if self._direct_mode == False: + #bufferにダミーオブジェクトを入れてis_alive()を判定させる + self._buffer.put({'chatdata':'','timeout':1}) + logger.info(f'終了しました:[{self.video_id}]') + + @classmethod + def shutdown(cls, event, sig = None, handler=None): + logger.debug("シャットダウンしています") + for t in LiveChat._listeners: + t._is_alive = False + + + + + + + + diff --git a/pytchat/core_multithread/parser.py b/pytchat/core_multithread/parser.py new file mode 100644 index 0000000..c4dada8 --- /dev/null +++ b/pytchat/core_multithread/parser.py @@ -0,0 +1,39 @@ +import json +from .. import config +from .. import mylogger +from .. exceptions import ( + ResponseContextError, + NoContentsException, + NoContinuationsException ) + + +logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE) + + +class Parser: + def parse(self, jsn): + if jsn is None: + return {'timeoutMs':0,'continuation':None},[] + if jsn['response']['responseContext'].get('errors'): + raise ResponseContextError('動画に接続できません。' + '動画IDが間違っているか、動画が削除/非公開の可能性があります。') + contents=jsn['response'].get('continuationContents') + #配信が終了した場合、もしくはチャットデータが取得できない場合 + if contents is None: + raise NoContentsException('チャットデータを取得できませんでした。') + + cont = contents['liveChatContinuation']['continuations'][0] + if cont is None: + raise NoContinuationsException('Continuationがありません。') + metadata = (cont.get('invalidationContinuationData') or + cont.get('timedContinuationData') or + cont.get('reloadContinuationData') + ) + if metadata is None: + unknown = list(cont.keys())[0] + if unknown: + logger.error(f"Received unknown continuation type:{unknown}") + metadata = cont.get(unknown) + metadata.setdefault('timeoutMs', 10000) + chatdata = contents['liveChatContinuation'].get('actions') + return metadata, chatdata diff --git a/pytchat/exceptions.py b/pytchat/exceptions.py new file mode 100644 index 0000000..b8a0045 --- /dev/null +++ b/pytchat/exceptions.py @@ -0,0 +1,43 @@ +class ChatParseException(Exception): + ''' + チャットデータをパースするライブラリが投げる例外の基底クラス + ''' + pass + +class NoYtinitialdataException(ChatParseException): + ''' + 配信ページ内にチャットデータurlが見つからないときに投げる例外 + ''' + pass + +class ResponseContextError(ChatParseException): + ''' + 配信ページでチャットデータ無効の時に投げる例外 + ''' + pass + +class NoLivechatRendererException(ChatParseException): + ''' + チャットデータのJSON中にlivechatRendererがない時に投げる例外 + ''' + pass + + +class NoContentsException(ChatParseException): + ''' + チャットデータのJSON中にContinuationContentsがない時に投げる例外 + ''' + pass + +class NoContinuationsException(ChatParseException): + ''' + チャットデータのContinuationContents中にcontinuationがない時に投げる例外 + ''' + pass + +class IllegalFunctionCall(Exception): + ''' + set_callback()を実行済みにもかかわらず + get()を呼び出した場合の例外 + ''' + pass diff --git a/pytchat/mylogger.py b/pytchat/mylogger.py new file mode 100644 index 0000000..4640307 --- /dev/null +++ b/pytchat/mylogger.py @@ -0,0 +1,31 @@ +import logging +import datetime + + +def get_logger(modname,mode=logging.DEBUG): + logger = logging.getLogger(modname) + if mode == None: + logger.addHandler(logging.NullHandler()) + return logger + logger.setLevel(mode) + #create handler1 for showing info + handler1 = logging.StreamHandler() + my_formatter = MyFormatter() + handler1.setFormatter(my_formatter) + + handler1.setLevel(mode) + logger.addHandler(handler1) + #create handler2 for recording log file + if mode <= logging.DEBUG: + handler2 = logging.FileHandler(filename="log.txt") + handler2.setLevel(logging.ERROR) + handler2.setFormatter(my_formatter) + + + logger.addHandler(handler2) + return logger + +class MyFormatter(logging.Formatter): + def format(self, record): + s =(datetime.datetime.fromtimestamp(record.created)).strftime("%m-%d %H:%M:%S")+'| '+ (record.module).ljust(15)+(' { '+record.funcName).ljust(20) +":"+str(record.lineno).rjust(4)+'} - '+record.getMessage() + return s diff --git a/pytchat/paramgen/__init__.py b/pytchat/paramgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/paramgen/liveparam.py b/pytchat/paramgen/liveparam.py new file mode 100644 index 0000000..cf0e5b6 --- /dev/null +++ b/pytchat/paramgen/liveparam.py @@ -0,0 +1,143 @@ +from base64 import urlsafe_b64encode as b64enc +from functools import reduce +import calendar, datetime, pytz +import math +import random +import urllib.parse + + +def _gen_vid(video_id): + """generate video_id parameter. + Parameter + --------- + video_id : str + + Return + --------- + byte[] : base64 encoded video_id parameter. + """ + header_magic = b'\x0A\x0F\x0A\x0D\x0A' + header_id = video_id.encode() + header_sep_1 = b'\x1A' + header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A' + header_suburl = ('https://www.youtube.com/live_chat?v=' + f'{video_id}&is_popout=1').encode() + header_terminator = b'\x20\x02' + + item = [ + header_magic, + _nval(len(header_id)), + header_id, + header_sep_1, + header_sep_2, + _nval(len(header_suburl)), + header_suburl, + header_terminator + ] + + return urllib.parse.quote( + b64enc(reduce(lambda x, y: x+y, item)).decode() + ).encode() + +def _nval(val): + """convert value to byte array""" + if val<0: raise ValueError + buf = b'' + while val >> 7: + m = val & 0xFF | 0x80 + buf += m.to_bytes(1,'big') + val >>= 7 + buf += val.to_bytes(1,'big') + return buf + +def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchatonly = False): + #_short_type2 + switch_01 = b'\x04' if topchatonly else b'\x01' + header_magic= b'\xD2\x87\xCC\xC8\x03' + + sep_0 = b'\x1A' + vid = _gen_vid(video_id) + time_tag = b'\x28' + timestamp1 = _nval(_ts1) + sep_1 = b'\x30\x00\x38\x00\x40\x02\x4A' + un_len = b'\x2B' + sep_2 = b'\x08\x00\x10\x00\x18\x00\x20\x00' + chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D' + sep_3 = b'\x3A\x00\x40\x00\x4A' + sep_4_len = b'\x02' + sep_4 = b'\x08\x01' + ts_2_start = b'\x50' + timestamp2 = _nval(_ts2) + ts_2_end = b'\x58' + sep_5 = b'\x03' + ts_3_start = b'\x50' + timestamp3 = _nval(_ts3) + ts_3_end = b'\x58' + timestamp4 = _nval(_ts4) + sep_6 = b'\x68' + #switch + sep_7 = b'\x82\x01\x04\x08' + #switch + sep_8 = b'\x10\x00' + sep_9 = b'\x88\x01\x00\xA0\x01' + timestamp5 = _nval(_ts5) + + body = [ + sep_0, + _nval(len(vid)), + vid, + time_tag, + timestamp1, + sep_1, + un_len, + sep_2, + chkstr, + sep_3, + sep_4_len, + sep_4, + ts_2_start, + timestamp2, + ts_2_end, + sep_5, + ts_3_start, + timestamp3, + ts_3_end, + timestamp4, + sep_6, + switch_01,# + sep_7, + switch_01,# + sep_8, + sep_9, + timestamp5 + ] + + body = reduce(lambda x, y: x+y, body) + + return urllib.parse.quote( + b64enc( header_magic + + _nval(len(body)) + + body + ).decode() + ) + + +def _times(): + + def unixts_now(): + now = datetime.datetime.now(pytz.utc) + return calendar.timegm(now.utctimetuple()) + + n = unixts_now() + + _ts1= n - random.uniform(0,1*3) + _ts2= n - random.uniform(0.01,0.99) + _ts3= n - 60*60 + random.uniform(0,1) + _ts4= n - random.uniform(10*60,60*60) + _ts5= n - random.uniform(0.01,0.99) + return list(map(lambda x:int(x*1000000),[_ts1,_ts2,_ts3,_ts4,_ts5])) + + +def getparam(video_id): + return _build(video_id,*_times()) + diff --git a/pytchat/processors/__init__.py b/pytchat/processors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/processors/chat_processor.py b/pytchat/processors/chat_processor.py new file mode 100644 index 0000000..8f79629 --- /dev/null +++ b/pytchat/processors/chat_processor.py @@ -0,0 +1,27 @@ +class ChatProcessor: + ''' + Listenerからチャットデータ(actions)を受け取り + チャットデータを加工するクラスの抽象クラス + ''' + def process(self, chat_components: list): + ''' + チャットデータの加工を表すインターフェース + Listenerから呼び出される。 + Parameter + ---------- + chat_components: list + component : dict { + "video_id" : str + 動画ID + "timeout" : int + 次のチャットの再読み込みまでの時間(秒) + "chatdata" : list + チャットデータ(actions)のリスト + } + ''' + pass + + + + + diff --git a/pytchat/processors/compatible/__init__.py b/pytchat/processors/compatible/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/processors/compatible/parser.py b/pytchat/processors/compatible/parser.py new file mode 100644 index 0000000..08cd7a1 --- /dev/null +++ b/pytchat/processors/compatible/parser.py @@ -0,0 +1,43 @@ + +from .renderer.textmessage import LiveChatTextMessageRenderer +from .renderer.paidmessage import LiveChatPaidMessageRenderer +from .renderer.paidsticker import LiveChatPaidStickerRenderer +from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer + +def parse(sitem): + + action = sitem.get("addChatItemAction") + if action: + item = action.get("item") + if item is None: return None + rd={} + try: + renderer = get_renderer(item) + if renderer == None: + return None + + rd["kind"] = "youtube#liveChatMessage" + rd["etag"] = "" + rd["id"] = 'LCC.' + renderer.get_id() + rd["snippet"] = renderer.get_snippet() + rd["authorDetails"] = renderer.get_authordetails() + except (KeyError,TypeError,AttributeError) as e: + print(f"------{str(type(e))}-{str(e)}----------") + print(sitem) + return None + + return rd + +def get_renderer(item): + if item.get("liveChatTextMessageRenderer"): + renderer = LiveChatTextMessageRenderer(item) + elif item.get("liveChatPaidMessageRenderer"): + renderer = LiveChatPaidMessageRenderer(item) + elif item.get( "liveChatPaidStickerRenderer"): + renderer = LiveChatPaidStickerRenderer(item) + elif item.get("liveChatLegacyPaidMessageRenderer"): + renderer = LiveChatLegacyPaidMessageRenderer(item) + else: + renderer = None + return renderer + diff --git a/pytchat/processors/compatible/processor.py b/pytchat/processors/compatible/processor.py new file mode 100644 index 0000000..277cbcf --- /dev/null +++ b/pytchat/processors/compatible/processor.py @@ -0,0 +1,39 @@ +from . import parser +import json +import os +import traceback +import datetime +import time +class CompatibleProcessor(): + + def process(self, chat_components: list): + + chatlist = [] + timeout = 0 + ret={} + ret["kind"] = "youtube#liveChatMessageListResponse" + ret["etag"] = "" + ret["nextPageToken"] = "" + + if chat_components: + for chat_component in chat_components: + timeout += chat_component.get('timeout', 0) + chatdata = chat_component.get('chatdata') + + if chatdata is None: break + for action in chatdata: + if action is None: continue + if action.get('addChatItemAction') is None: continue + if action['addChatItemAction'].get('item') is None: continue + + chat = parser.parse(action) + if chat: + chatlist.append(chat) + ret["pollingIntervalMillis"] = int(timeout*1000) + ret["pageInfo"]={ + "totalResults":len(chatlist), + "resultsPerPage":len(chatlist), + } + ret["items"] = chatlist + + return ret \ No newline at end of file diff --git a/pytchat/processors/compatible/renderer/__init__.py b/pytchat/processors/compatible/renderer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/processors/compatible/renderer/base.py b/pytchat/processors/compatible/renderer/base.py new file mode 100644 index 0000000..d9003f9 --- /dev/null +++ b/pytchat/processors/compatible/renderer/base.py @@ -0,0 +1,83 @@ +import datetime, pytz + +class BaseRenderer: + def __init__(self, item, chattype): + self.renderer = list(item.values())[0] + self.chattype = chattype + + + def get_snippet(self): + + message = self.get_message(self.renderer) + + return { + "type" : self.chattype, + "liveChatId" : "", + "authorChannelId" : self.renderer.get("authorExternalChannelId"), + "publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)), + "hasDisplayContent" : True, + "displayMessage" : message, + "textMessageDetails": { + "messageText" : message + } + } + + + def get_authordetails(self): + authorExternalChannelId = self.renderer.get("authorExternalChannelId") + #parse subscriber type + isVerified, isChatOwner, isChatSponsor, isChatModerator = ( + self.get_badges(self.renderer) + ) + return { + "channelId" : authorExternalChannelId, + "channelUrl" : "http://www.youtube.com/channel/"+authorExternalChannelId, + "displayName" : self.renderer["authorName"]["simpleText"], + "profileImageUrl" : self.renderer["authorPhoto"]["thumbnails"][1]["url"] , + "isVerified" : isVerified, + "isChatOwner" : isChatOwner, + "isChatSponsor" : isChatSponsor, + "isChatModerator" : isChatModerator + } + + + def get_message(self,renderer): + message = '' + if renderer.get("message"): + runs=renderer["message"].get("runs") + if runs: + for r in runs: + if r: + if r.get('emoji'): + message += r['emoji'].get('shortcuts',[''])[0] + else: + message += r.get('text','') + return message + + def get_badges(self,renderer): + isVerified = False + isChatOwner = False + isChatSponsor = False + isChatModerator = False + badges=renderer.get("authorBadges") + if badges: + for badge in badges: + author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"] + if author_type == '確認済み': + isVerified = True + if author_type == '所有者': + isChatOwner = True + if 'メンバー' in author_type: + isChatSponsor = True + if author_type == 'モデレーター': + isChatModerator = True + return isVerified, isChatOwner, isChatSponsor, isChatModerator + + def get_id(self): + return self.renderer.get('id') + + def get_publishedat(self,timestamp): + dt = datetime.datetime.fromtimestamp(int(timestamp)/1000000) + return dt.astimezone(pytz.utc).isoformat( + timespec='milliseconds').replace('+00:00','Z') + \ No newline at end of file diff --git a/pytchat/processors/compatible/renderer/currency.py b/pytchat/processors/compatible/renderer/currency.py new file mode 100644 index 0000000..1959a79 --- /dev/null +++ b/pytchat/processors/compatible/renderer/currency.py @@ -0,0 +1,37 @@ +''' +YouTubeスーパーチャットで使用される通貨の記号とレート検索用の略号の +対応表 +Key: + YouTubeスーパーチャットで使用される通貨の記号 + (アルファベットで終わる場合、0xA0(&npsp)が付く) +Value: + fxtext: 3文字の通貨略称 + jptest: 日本語テキスト +''' +symbols = { + "$": {"fxtext": "USD", "jptext": "米・ドル"}, + "A$": {"fxtext": "AUD", "jptext": "オーストラリア・ドル"}, + "CA$": {"fxtext": "CAD", "jptext": "カナダ・ドル"}, + "CHF\xa0": {"fxtext": "CHF", "jptext": "スイス・フラン"}, + "COP\xa0": {"fxtext": "COP", "jptext": "コロンビア・ペソ"}, + "HK$": {"fxtext": "HKD", "jptext": "香港・ドル"}, + "HUF\xa0": {"fxtext": "HUF", "jptext": "ハンガリー・フォリント"}, + "MX$": {"fxtext": "MXN", "jptext": "メキシコ・ペソ"}, + "NT$": {"fxtext": "TWD", "jptext": "台湾・ドル"}, + "NZ$": {"fxtext": "NZD", "jptext": "ニュージーランド・ドル"}, + "PHP\xa0": {"fxtext": "PHP", "jptext": "フィリピン・ペソ"}, + "PLN\xa0": {"fxtext": "PLN", "jptext": "ポーランド・ズロチ"}, + "R$": {"fxtext": "BRL", "jptext": "ブラジル・レアル"}, + "RUB\xa0": {"fxtext": "RUB", "jptext": "ロシア・ルーブル"}, + "SEK\xa0": {"fxtext": "SEK", "jptext": "スウェーデン・クローネ"}, + "£": {"fxtext": "GBP", "jptext": "英・ポンド"}, + "₩": {"fxtext": "KRW", "jptext": "韓国・ウォン"}, + "€": {"fxtext": "EUR", "jptext": "欧・ユーロ"}, + "₹": {"fxtext": "INR", "jptext": "インド・ルピー"}, + "¥": {"fxtext": "JPY", "jptext": "日本・円"}, + "PEN\xa0": {"fxtext": "PEN", "jptext": "ペルー・ヌエボ・ソル"}, + "ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"}, + "CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"}, + "NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"}, + "BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"} +} \ No newline at end of file diff --git a/pytchat/processors/compatible/renderer/legacypaid.py b/pytchat/processors/compatible/renderer/legacypaid.py new file mode 100644 index 0000000..1b31631 --- /dev/null +++ b/pytchat/processors/compatible/renderer/legacypaid.py @@ -0,0 +1,43 @@ +from .base import BaseRenderer +class LiveChatLegacyPaidMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "newSponsorEvent") + + def get_snippet(self): + + message = self.get_message(self.renderer) + + return { + "type" : self.chattype, + "liveChatId" : "", + "authorChannelId" : self.renderer.get("authorExternalChannelId"), + "publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)), + "hasDisplayContent" : True, + "displayMessage" : message, + + } + + def get_authordetails(self): + authorExternalChannelId = self.renderer.get("authorExternalChannelId") + #parse subscriber type + isVerified, isChatOwner, _, isChatModerator = ( + self.get_badges(self.renderer) + ) + return { + "channelId" : authorExternalChannelId, + "channelUrl" : "http://www.youtube.com/channel/"+authorExternalChannelId, + "displayName" : self.renderer["authorName"]["simpleText"], + "profileImageUrl" : self.renderer["authorPhoto"]["thumbnails"][1]["url"] , + "isVerified" : isVerified, + "isChatOwner" : isChatOwner, + "isChatSponsor" : True, + "isChatModerator" : isChatModerator + } + + + def get_message(self,renderer): + message = (renderer["eventText"]["runs"][0]["text"] + )+' / '+(renderer["detailText"]["simpleText"]) + return message + + diff --git a/pytchat/processors/compatible/renderer/paidmessage.py b/pytchat/processors/compatible/renderer/paidmessage.py new file mode 100644 index 0000000..d5c2615 --- /dev/null +++ b/pytchat/processors/compatible/renderer/paidmessage.py @@ -0,0 +1,41 @@ +import re +from . import currency +from .base import BaseRenderer +superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$") + +class LiveChatPaidMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "superChatEvent") + + def get_snippet(self): + authorName = self.renderer["authorName"]["simpleText"] + message = self.get_message(self.renderer) + amountDisplayString, symbol, amountMicros =( + self.get_amountdata(self.renderer) + ) + return { + "type" : self.chattype, + "liveChatId" : "", + "authorChannelId" : self.renderer.get("authorExternalChannelId"), + "publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)), + "hasDisplayContent" : True, + "displayMessage" : amountDisplayString+" from "+authorName+': \"'+ message+'\"', + "superChatDetails" : { + "amountMicros" : amountMicros, + "currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol, + "amountDisplayString" : amountDisplayString, + "tier" : 0, + "backgroundColor" : self.renderer.get("bodyBackgroundColor", 0) + } + } + + def get_amountdata(self,renderer): + amountDisplayString = renderer["purchaseAmountText"]["simpleText"] + m = superchat_regex.search(amountDisplayString) + if m: + symbol = m.group(1) + amountMicros = int(float(m.group(2).replace(',',''))*1000000) + else: + symbol = "" + amountMicros = 0 + return amountDisplayString, symbol, amountMicros \ No newline at end of file diff --git a/pytchat/processors/compatible/renderer/paidsticker.py b/pytchat/processors/compatible/renderer/paidsticker.py new file mode 100644 index 0000000..20abf32 --- /dev/null +++ b/pytchat/processors/compatible/renderer/paidsticker.py @@ -0,0 +1,48 @@ +import re +from . import currency +from .base import BaseRenderer +superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$") + +class LiveChatPaidStickerRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "superStickerEvent") + + def get_snippet(self): + authorName = self.renderer["authorName"]["simpleText"] + amountDisplayString, symbol, amountMicros =( + self.get_amountdata(self.renderer) + ) + + return { + "type" : self.chattype, + "liveChatId" : "", + "authorChannelId" : self.renderer.get("authorExternalChannelId"), + "publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)), + "hasDisplayContent" : True, + "displayMessage" : "Super Sticker " + amountDisplayString + " from "+authorName, + "superStickerDetails" : { + "superStickerMetaData" : { + "stickerId": "", + "altText": "", + "language": "" + }, + "amountMicros" : amountMicros, + "currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol, + "amountDisplayString" : amountDisplayString, + "tier" : 0, + "backgroundColor" : self.renderer.get("bodyBackgroundColor", 0) + } + } + + def get_amountdata(self,renderer): + amountDisplayString = renderer["purchaseAmountText"]["simpleText"] + m = superchat_regex.search(amountDisplayString) + if m: + symbol = m.group(1) + amountMicros = int(float(m.group(2).replace(',',''))*1000000) + else: + symbol = "" + amountMicros = 0 + return amountDisplayString, symbol, amountMicros + + diff --git a/pytchat/processors/compatible/renderer/textmessage.py b/pytchat/processors/compatible/renderer/textmessage.py new file mode 100644 index 0000000..dae62f1 --- /dev/null +++ b/pytchat/processors/compatible/renderer/textmessage.py @@ -0,0 +1,4 @@ +from .base import BaseRenderer +class LiveChatTextMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "textMessageEvent") diff --git a/pytchat/processors/default/__init__.py b/pytchat/processors/default/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/processors/default/parser.py b/pytchat/processors/default/parser.py new file mode 100644 index 0000000..eec4274 --- /dev/null +++ b/pytchat/processors/default/parser.py @@ -0,0 +1,39 @@ + +from .renderer.textmessage import LiveChatTextMessageRenderer +from .renderer.paidmessage import LiveChatPaidMessageRenderer +from .renderer.paidsticker import LiveChatPaidStickerRenderer +from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer + +def parse(sitem): + + action = sitem.get("addChatItemAction") + if action: + item = action.get("item") + if item is None: return None + try: + renderer = get_renderer(item) + if renderer == None: + return None + + renderer.get_snippet() + renderer.get_authordetails() + except (KeyError,TypeError,AttributeError) as e: + print(f"------{str(type(e))}-{str(e)}----------") + print(sitem) + return None + + return renderer + +def get_renderer(item): + if item.get("liveChatTextMessageRenderer"): + renderer = LiveChatTextMessageRenderer(item) + elif item.get("liveChatPaidMessageRenderer"): + renderer = LiveChatPaidMessageRenderer(item) + elif item.get( "liveChatPaidStickerRenderer"): + renderer = LiveChatPaidStickerRenderer(item) + elif item.get("liveChatLegacyPaidMessageRenderer"): + renderer = LiveChatLegacyPaidMessageRenderer(item) + else: + renderer = None + return renderer + diff --git a/pytchat/processors/default/processor.py b/pytchat/processors/default/processor.py new file mode 100644 index 0000000..0b063fa --- /dev/null +++ b/pytchat/processors/default/processor.py @@ -0,0 +1,44 @@ +from . import parser +import asyncio +import time + + +class Chatdata: + def __init__(self,chatlist:list, timeout:float): + self.items = chatlist + self.interval = timeout + + def tick(self): + if self.interval == 0: + time.sleep(3) + return + time.sleep(self.interval/len(self.items)) + + async def tick_async(self): + if self.interval == 0: + await asyncio.sleep(3) + return + await asyncio.sleep(self.interval/len(self.items)) + +class DefaultProcessor: + def process(self, chat_components: list): + + chatlist = [] + timeout = 0 + + if chat_components: + for component in chat_components: + timeout += component.get('timeout', 0) + chatdata = component.get('chatdata') + + if chatdata is None: continue + for action in chatdata: + if action is None: continue + if action.get('addChatItemAction') is None: continue + if action['addChatItemAction'].get('item') is None: continue + + chat = parser.parse(action) + if chat: + chatlist.append(chat) + return Chatdata(chatlist, float(timeout)) + diff --git a/pytchat/processors/default/renderer/__init__.py b/pytchat/processors/default/renderer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytchat/processors/default/renderer/base.py b/pytchat/processors/default/renderer/base.py new file mode 100644 index 0000000..c555b70 --- /dev/null +++ b/pytchat/processors/default/renderer/base.py @@ -0,0 +1,80 @@ +from datetime import datetime + +class Author: + pass +class BaseRenderer: + def __init__(self, item, chattype): + self.renderer = list(item.values())[0] + self.chattype = chattype + self.author = Author() + + def get_snippet(self): + self.type = self.chattype + self.id = self.renderer.get('id') + timestampUsec = int(self.renderer.get("timestampUsec",0)) + self.timestamp = int(timestampUsec/1000) + self.datetime = self.get_datetime(timestampUsec) + self.message = self.get_message(self.renderer) + self.id = self.renderer.get('id') + self.amountValue= 0.0 + self.amountString = "" + self.currency= "" + self.bgColor = 0 + + def get_authordetails(self): + self.author.badgeUrl = "" + (self.author.isVerified, + self.author.isChatOwner, + self.author.isChatSponsor, + self.author.isChatModerator) = ( + self.get_badges(self.renderer) + ) + self.author.channelId = self.renderer.get("authorExternalChannelId") + self.author.channelUrl = "http://www.youtube.com/channel/"+self.author.channelId + self.author.name = self.renderer["authorName"]["simpleText"] + self.author.imageUrl= self.renderer["authorPhoto"]["thumbnails"][1]["url"] + + + + def get_message(self,renderer): + message = '' + if renderer.get("message"): + runs=renderer["message"].get("runs") + if runs: + for r in runs: + if r: + if r.get('emoji'): + message += r['emoji'].get('shortcuts',[''])[0] + else: + message += r.get('text','') + return message + + def get_badges(self,renderer): + isVerified = False + isChatOwner = False + isChatSponsor = False + isChatModerator = False + badges=renderer.get("authorBadges") + if badges: + for badge in badges: + author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"] + if author_type == '確認済み': + isVerified = True + if author_type == '所有者': + isChatOwner = True + if 'メンバー' in author_type: + isChatSponsor = True + self.get_badgeurl(badge) + if author_type == 'モデレーター': + isChatModerator = True + return isVerified, isChatOwner, isChatSponsor, isChatModerator + + + def get_badgeurl(self,badge): + self.author.badgeUrl = badge["liveChatAuthorBadgeRenderer"]["customThumbnail"]["thumbnails"][0]["url"] + + + + def get_datetime(self,timestamp): + dt = datetime.fromtimestamp(timestamp/1000000) + return dt.strftime('%Y-%m-%d %H:%M:%S') \ No newline at end of file diff --git a/pytchat/processors/default/renderer/currency.py b/pytchat/processors/default/renderer/currency.py new file mode 100644 index 0000000..1959a79 --- /dev/null +++ b/pytchat/processors/default/renderer/currency.py @@ -0,0 +1,37 @@ +''' +YouTubeスーパーチャットで使用される通貨の記号とレート検索用の略号の +対応表 +Key: + YouTubeスーパーチャットで使用される通貨の記号 + (アルファベットで終わる場合、0xA0(&npsp)が付く) +Value: + fxtext: 3文字の通貨略称 + jptest: 日本語テキスト +''' +symbols = { + "$": {"fxtext": "USD", "jptext": "米・ドル"}, + "A$": {"fxtext": "AUD", "jptext": "オーストラリア・ドル"}, + "CA$": {"fxtext": "CAD", "jptext": "カナダ・ドル"}, + "CHF\xa0": {"fxtext": "CHF", "jptext": "スイス・フラン"}, + "COP\xa0": {"fxtext": "COP", "jptext": "コロンビア・ペソ"}, + "HK$": {"fxtext": "HKD", "jptext": "香港・ドル"}, + "HUF\xa0": {"fxtext": "HUF", "jptext": "ハンガリー・フォリント"}, + "MX$": {"fxtext": "MXN", "jptext": "メキシコ・ペソ"}, + "NT$": {"fxtext": "TWD", "jptext": "台湾・ドル"}, + "NZ$": {"fxtext": "NZD", "jptext": "ニュージーランド・ドル"}, + "PHP\xa0": {"fxtext": "PHP", "jptext": "フィリピン・ペソ"}, + "PLN\xa0": {"fxtext": "PLN", "jptext": "ポーランド・ズロチ"}, + "R$": {"fxtext": "BRL", "jptext": "ブラジル・レアル"}, + "RUB\xa0": {"fxtext": "RUB", "jptext": "ロシア・ルーブル"}, + "SEK\xa0": {"fxtext": "SEK", "jptext": "スウェーデン・クローネ"}, + "£": {"fxtext": "GBP", "jptext": "英・ポンド"}, + "₩": {"fxtext": "KRW", "jptext": "韓国・ウォン"}, + "€": {"fxtext": "EUR", "jptext": "欧・ユーロ"}, + "₹": {"fxtext": "INR", "jptext": "インド・ルピー"}, + "¥": {"fxtext": "JPY", "jptext": "日本・円"}, + "PEN\xa0": {"fxtext": "PEN", "jptext": "ペルー・ヌエボ・ソル"}, + "ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"}, + "CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"}, + "NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"}, + "BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"} +} \ No newline at end of file diff --git a/pytchat/processors/default/renderer/legacypaid.py b/pytchat/processors/default/renderer/legacypaid.py new file mode 100644 index 0000000..12dfde5 --- /dev/null +++ b/pytchat/processors/default/renderer/legacypaid.py @@ -0,0 +1,18 @@ +from .base import BaseRenderer +class LiveChatLegacyPaidMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "newSponsor") + + + + def get_authordetails(self): + super().get_authordetails() + self.author.isChatSponsor = True + + + def get_message(self,renderer): + message = (renderer["eventText"]["runs"][0]["text"] + )+' / '+(renderer["detailText"]["simpleText"]) + return message + + diff --git a/pytchat/processors/default/renderer/paidmessage.py b/pytchat/processors/default/renderer/paidmessage.py new file mode 100644 index 0000000..270d63c --- /dev/null +++ b/pytchat/processors/default/renderer/paidmessage.py @@ -0,0 +1,36 @@ +import re +from . import currency +from .base import BaseRenderer +superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$") + +class LiveChatPaidMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "superChat") + + + def get_snippet(self): + super().get_snippet() + + self.author.name = self.renderer["authorName"]["simpleText"] + + amountDisplayString, symbol, amount =( + self.get_amountdata(self.renderer) + ) + self.message = self.get_message(self.renderer) + self.amountValue= amount + self.amountString = amountDisplayString + self.currency= currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol + self.bgColor= self.renderer.get("bodyBackgroundColor", 0) + + + + def get_amountdata(self,renderer): + amountDisplayString = renderer["purchaseAmountText"]["simpleText"] + m = superchat_regex.search(amountDisplayString) + if m: + symbol = m.group(1) + amount = float(m.group(2).replace(',','')) + else: + symbol = "" + amount = 0.0 + return amountDisplayString, symbol, amount \ No newline at end of file diff --git a/pytchat/processors/default/renderer/paidsticker.py b/pytchat/processors/default/renderer/paidsticker.py new file mode 100644 index 0000000..5d00bf2 --- /dev/null +++ b/pytchat/processors/default/renderer/paidsticker.py @@ -0,0 +1,13 @@ +import re +from . import currency +from .paidmessage import LiveChatPaidMessageRenderer + +class LiveChatPaidStickerRenderer(LiveChatPaidMessageRenderer): + def __init__(self, item): + super().__init__(item, "superSticker") + + + + + + diff --git a/pytchat/processors/default/renderer/textmessage.py b/pytchat/processors/default/renderer/textmessage.py new file mode 100644 index 0000000..131f8b3 --- /dev/null +++ b/pytchat/processors/default/renderer/textmessage.py @@ -0,0 +1,4 @@ +from .base import BaseRenderer +class LiveChatTextMessageRenderer(BaseRenderer): + def __init__(self, item): + super().__init__(item, "textMessage") diff --git a/pytchat/processors/json_display_processor.py b/pytchat/processors/json_display_processor.py new file mode 100644 index 0000000..ba8e442 --- /dev/null +++ b/pytchat/processors/json_display_processor.py @@ -0,0 +1,13 @@ +import json +from .chat_processor import ChatProcessor + +class JsonDisplayProcessor(ChatProcessor): + + def process(self,chat_components: list): + if chat_components: + for component in chat_components: + chatdata = component.get('chatdata') + if chatdata: + for chat in chatdata: + print(json.dumps(chat,ensure_ascii=False)[:200]) + diff --git a/pytchat/processors/jsonfile_archive_processor.py b/pytchat/processors/jsonfile_archive_processor.py new file mode 100644 index 0000000..5a5f3a1 --- /dev/null +++ b/pytchat/processors/jsonfile_archive_processor.py @@ -0,0 +1,46 @@ +import json +import os +import datetime +from .chat_processor import ChatProcessor + +class JsonfileArchiveProcessor(ChatProcessor): + def __init__(self,filepath): + super().__init__() + if os.path.exists(filepath): + print('filepath is already exists!: ') + print(' '+filepath) + newpath=os.path.dirname(filepath) + \ + '/'+datetime.datetime.now() \ + .strftime('%Y-%m-%d %H-%M-%S')+'.data' + + print('created alternate filename:') + print(' '+newpath) + self.filepath = newpath + else: + print('filepath: '+filepath) + self.filepath = filepath + + def process(self,chat_components: list): + if chat_components: + with open(self.filepath, mode='a', encoding = 'utf-8') as f: + for component in chat_components: + if component: + chatdata = component.get('chatdata') + for action in chatdata: + if action: + if action.get("addChatItemAction"): + if action["addChatItemAction"]["item"].get( + "liveChatViewerEngagementMessageRenderer"): + continue + s = json.dumps(action,ensure_ascii = False) + #print(s[:200]) + f.writelines(s+'\n') + + def _parsedir(self,_dir): + if _dir[-1]=='\\' or _dir[-1]=='/': + separator ='' + else: + separator ='/' + os.makedirs(_dir + separator, exist_ok=True) + return _dir + separator + diff --git a/pytchat/processors/simple_display_processor.py b/pytchat/processors/simple_display_processor.py new file mode 100644 index 0000000..281b3df --- /dev/null +++ b/pytchat/processors/simple_display_processor.py @@ -0,0 +1,61 @@ +import json +import os +import traceback +import datetime +import time +from .chat_processor import ChatProcessor +##version 2 +class SimpleDisplayProcessor(ChatProcessor): + + def process(self, chat_components: list): + chatlist = [] + timeout = 0 + + if chat_components is None: + return {"timeout":timeout, "chatlist":chatlist} + for component in chat_components: + timeout += component.get('timeout', 0) + chatdata = component.get('chatdata') + + if chatdata is None:break + for action in chatdata: + if action is None:continue + if action.get('addChatItemAction') is None:continue + if action['addChatItemAction'].get('item') is None:continue + + root = action['addChatItemAction']['item'].get('liveChatTextMessageRenderer') + + if root: + author_name = root['authorName']['simpleText'] + message = self._parse_message(root.get('message')) + purchase_amount_text = '' + else: + root = ( action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer') or + action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer') ) + if root: + author_name = root['authorName']['simpleText'] + message = self._parse_message(root.get('message')) + purchase_amount_text = root['purchaseAmountText']['simpleText'] + else: + continue + chatlist.append(f'[{author_name}]: {message} {purchase_amount_text}') + return {"timeout":timeout, "chatlist":chatlist} + + def _parse_message(self,message): + if message is None: + return '' + if message.get('simpleText'): + return message['simpleText'] + elif message.get('runs'): + runs = message['runs'] + tmp = '' + for run in runs: + if run.get('emoji'): + tmp+=(run['emoji']['shortcuts'][0]) + elif run.get('text'): + tmp+=(run['text']) + return tmp + else: + return '' + + diff --git a/pytchat/util/__init__.py b/pytchat/util/__init__.py new file mode 100644 index 0000000..24b8e95 --- /dev/null +++ b/pytchat/util/__init__.py @@ -0,0 +1,15 @@ +import requests,json,datetime +from .. import config + +def download(cls,url): + _session = requests.Session() + html = _session.get(url, headers=config.headers) + with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S') + )+'test.json',mode ='w',encoding='utf-8') as f: + json.dump(html.json(),f,ensure_ascii=False) + + +def save(cls,data,filename): + with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S') + )+filename,mode ='w',encoding='utf-8') as f: + f.writelines(data) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..619d39a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +aiohttp==3.6.0 +aioresponses==0.6.0 +mock==3.0.5 +mocker==1.1.1 +pytest==5.1.2 +pytest-mock==1.10.4 +pytz==2019.2 +requests==2.22.0 +urllib3==1.25.3 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..70d53a0 --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +from setuptools import setup, find_packages +from codecs import open +from os import path +import re + +package_name = "pytchat" + +root_dir = path.abspath(path.dirname(__file__)) + +def _requires_from_file(filename): + return open(filename).read().splitlines() + +with open(path.join(root_dir, package_name, '__init__.py')) as f: + init_text = f.read() + version = re.search(r'__version__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1) + license = re.search(r'__license__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1) + author = re.search(r'__author__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1) + author_email = re.search(r'__author_email__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1) + url = re.search(r'__url__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1) + +assert version +assert license +assert author +assert author_email +assert url + +with open('README.md', encoding='utf-8') as f: + long_description = f.read() + +setup( + name=package_name, + packages=[package_name], + + version=version, + + url=url, + author=author, + author_email=author_email, + long_description=long_description, + long_description_content_type='text/markdown', + license=license, + description="a python library for fetching youtube live chat.", + classifiers=[ + 'Natural Language :: Japanese', + 'Development Status :: 4 - Beta', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'License :: OSI Approved :: MIT License', + ], + keywords='youtube livechat asyncio', + install_requires=_requires_from_file('requirements.txt') +) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_compatible_processor.py b/tests/test_compatible_processor.py new file mode 100644 index 0000000..c4daa56 --- /dev/null +++ b/tests/test_compatible_processor.py @@ -0,0 +1,128 @@ +import json +import pytest +import asyncio,aiohttp +from pytchat.core_async.parser import Parser +from pytchat.processors.compatible.processor import CompatibleProcessor +from pytchat.exceptions import ( + NoLivechatRendererException,NoYtinitialdataException, + ResponseContextError, NoContentsException) + +from pytchat.processors.compatible.renderer.textmessage import LiveChatTextMessageRenderer +from pytchat.processors.compatible.renderer.paidmessage import LiveChatPaidMessageRenderer +from pytchat.processors.compatible.renderer.paidsticker import LiveChatPaidStickerRenderer +from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaidMessageRenderer + +def test_textmessage(mocker): + '''api互換processorのテスト:通常テキストメッセージ''' + processor = CompatibleProcessor() + + _json = _open_file("tests/testdata/compatible/textmessage.json") + + _, chatdata = Parser.parse(json.loads(_json)) + data = { + "video_id" : "", + "timeout" : 7, + "chatdata" : chatdata + } + ret = processor.process([data]) + + assert ret["kind"]== "youtube#liveChatMessageListResponse" + assert ret["pollingIntervalMillis"]==data["timeout"]*1000 + assert ret.keys() == { + "kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" + } + assert ret["pageInfo"].keys() == { + "totalResults", "resultsPerPage" + } + assert ret["items"][0].keys() == { + "kind", "etag", "id", "snippet", "authorDetails" + } + assert ret["items"][0]["snippet"].keys() == { + 'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage', 'textMessageDetails' + } + assert ret["items"][0]["authorDetails"].keys() == { + 'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator' + } + assert ret["items"][0]["snippet"]["textMessageDetails"].keys() == { + 'messageText' + } + assert "LCC." in ret["items"][0]["id"] + assert ret["items"][0]["snippet"]["type"]=="textMessageEvent" + +def test_newsponcer(mocker): + '''api互換processorのテスト:メンバ新規登録''' + processor = CompatibleProcessor() + + _json = _open_file("tests/testdata/compatible/newSponsor.json") + + _, chatdata = Parser.parse(json.loads(_json)) + data = { + "video_id" : "", + "timeout" : 7, + "chatdata" : chatdata + } + ret = processor.process([data]) + + assert ret["kind"]== "youtube#liveChatMessageListResponse" + assert ret["pollingIntervalMillis"]==data["timeout"]*1000 + assert ret.keys() == { + "kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" + } + assert ret["pageInfo"].keys() == { + "totalResults", "resultsPerPage" + } + assert ret["items"][0].keys() == { + "kind", "etag", "id", "snippet","authorDetails" + } + assert ret["items"][0]["snippet"].keys() == { + 'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage' + + } + assert ret["items"][0]["authorDetails"].keys() == { + 'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator' + } + assert "LCC." in ret["items"][0]["id"] + assert ret["items"][0]["snippet"]["type"]=="newSponsorEvent" + + +def test_superchat(mocker): + '''api互換processorのテスト:スパチャ''' + processor = CompatibleProcessor() + + _json = _open_file("tests/testdata/compatible/superchat.json") + + _, chatdata = Parser.parse(json.loads(_json)) + data = { + "video_id" : "", + "timeout" : 7, + "chatdata" : chatdata + } + ret = processor.process([data]) + + assert ret["kind"]== "youtube#liveChatMessageListResponse" + assert ret["pollingIntervalMillis"]==data["timeout"]*1000 + assert ret.keys() == { + "kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" + } + assert ret["pageInfo"].keys() == { + "totalResults", "resultsPerPage" + } + assert ret["items"][0].keys() == { + "kind", "etag", "id", "snippet", "authorDetails" + } + assert ret["items"][0]["snippet"].keys() == { + 'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage', 'superChatDetails' + } + assert ret["items"][0]["authorDetails"].keys() == { + 'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator' + } + assert ret["items"][0]["snippet"]["superChatDetails"].keys() == { + 'amountMicros', 'currency', 'amountDisplayString', 'tier', 'backgroundColor' + } + assert "LCC." in ret["items"][0]["id"] + assert ret["items"][0]["snippet"]["type"]=="superChatEvent" + + +def _open_file(path): + with open(path,mode ='r',encoding = 'utf-8') as f: + return f.read() diff --git a/tests/test_livechat.py b/tests/test_livechat.py new file mode 100644 index 0000000..daa69e0 --- /dev/null +++ b/tests/test_livechat.py @@ -0,0 +1,53 @@ +import pytest +from pytchat.core_async.parser import Parser as AsyncParser +from pytchat.core_multithread.parser import Parser as ThreadParser +import json +import asyncio,aiohttp + +from aioresponses import aioresponses +from pytchat.core_async.livechat import LiveChatAsync +from pytchat.exceptions import ( + NoLivechatRendererException,NoYtinitialdataException, + ResponseContextError,NoContentsException) + + +from pytchat.core_multithread.livechat import LiveChat +import unittest +from unittest import TestCase + +def _open_file(path): + with open(path,mode ='r',encoding = 'utf-8') as f: + return f.read() + +@aioresponses() +def test_Async(*mock): + vid='' + _text = _open_file('tests/testdata/paramgen_firstread.json') + _text = json.loads(_text) + mock[0].get(f"https://www.youtube.com/live_chat?v={vid}&is_popout=1", status=200, body=_text) + try: + chat = LiveChatAsync(video_id='') + assert chat.is_alive() + chat.terminate() + assert not chat.is_alive() + except ResponseContextError: + assert not chat.is_alive() + +def test_MultiThread(mocker): + _text = _open_file('tests/testdata/paramgen_firstread.json') + _text = json.loads(_text) + responseMock = mocker.Mock() + responseMock.status_code = 200 + responseMock.text = _text + mocker.patch('requests.Session.get').return_value = responseMock + try: + chat = LiveChatAsync(video_id='') + assert chat.is_alive() + chat.terminate() + assert not chat.is_alive() + except ResponseContextError: + chat.terminate() + assert not chat.is_alive() + + + diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..31ed8c3 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,44 @@ +import pytest +from pytchat.core_async.parser import Parser +import json +import asyncio,aiohttp +from aioresponses import aioresponses +from pytchat.exceptions import ( + NoLivechatRendererException,NoYtinitialdataException, + ResponseContextError, NoContentsException) + + +def _open_file(path): + with open(path,mode ='r',encoding = 'utf-8') as f: + return f.read() + + +@aioresponses() +def test_finishedlive(*mock): + '''配信が終了した動画を正しく処理できるか''' + + _text = _open_file('tests/testdata/finished_live.json') + _text = json.loads(_text) + + try: + Parser.parse(_text) + assert False + except NoContentsException: + assert True + +@aioresponses() +def test_parsejson(*mock): + '''jsonを正常にパースできるか''' + + _text = _open_file('tests/testdata/paramgen_firstread.json') + _text = json.loads(_text) + + try: + Parser.parse(_text) + jsn = _text + timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"] + continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"] + assert 5035 == timeout + assert "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D" == continuation + except: + assert False \ No newline at end of file diff --git a/tests/testdata/compatible/API_NewSponsor.json b/tests/testdata/compatible/API_NewSponsor.json new file mode 100644 index 0000000..724a573 --- /dev/null +++ b/tests/testdata/compatible/API_NewSponsor.json @@ -0,0 +1,35 @@ +{ + "kind": "youtube#liveChatMessageListResponse", + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "nextPageToken": "GPaimtHdmuUCIJi18ez1muUC", + "pollingIntervalMillis": 7000, + "pageInfo": { + "totalResults": 1, + "resultsPerPage": 1 + }, + "items": [ + { + "kind": "youtube#liveChatMessage", + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "id": "LCC.Ck7NAO9vS2nHct38SwUnWfkQy5kxNdHJqrL0dYOb3izTAHB74jBnWRccAia8DURhN1h8oT79FH94gMnQ2OTfv5BPev1huOtEfAHjciF8TWsyi9LeJWdOu7HJ4JoFquok7dv6tD9chf6wqpWz", + "snippet": { + "type": "newSponsorEvent", + "liveChatId": "oQZoCxfO7oUMBf703MdPwXb4c3EwgfI2Po4sL1oBaKbMelkCBiOS5UPOzE62l7hbC249v8wSTXcyEcVFHklmClse41wwiyO97uUyrt", + "authorChannelId": "UCZneZy3197k3ErGxVjKFZSg", + "publishedAt": "2018-01-01T00:00:00.000Z", + "hasDisplayContent": true, + "displayMessage": "NEW MEMBER! Welcome A!" + }, + "authorDetails": { + "channelId": "UCZneZy3197k3ErGxVjKFZSg", + "channelUrl": "http://www.youtube.com/channel/UCZneZy3197k3ErGxVjKFZSg", + "displayName": "A", + "profileImageUrl": "https://yt3.ggpht.com/-Dvb6csYDHb9/AAAAAAAAAAI/AAAAAAAAAAA/Bvb6csYDHb9/s88-c-k-no-mo-rj-c0xffffff/photo.jpg", + "isVerified": false, + "isChatOwner": false, + "isChatSponsor": true, + "isChatModerator": false + } + } + ] +} diff --git a/tests/testdata/compatible/API_SuperChat.json b/tests/testdata/compatible/API_SuperChat.json new file mode 100644 index 0000000..3fa7200 --- /dev/null +++ b/tests/testdata/compatible/API_SuperChat.json @@ -0,0 +1,42 @@ +{ + "kind": "youtube#liveChatMessageListResponse", + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "nextPageToken": "GPaimtHdmuUCIJi18ez1muUC", + "pollingIntervalMillis": 4000, + "pageInfo": { + "totalResults": 75, + "resultsPerPage": 75 + }, + "items": [ + { + "kind": "youtube#liveChatMessage", + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "id": "LCC.Ck7NAO9vS2nHct38SwUnWfkQy5kxNdHJqrL0dYOb3izTAHB74jBnWRccAia8DURhN1h8oT79FH94gMnQ2OTfv5BPev1huOtEfAHjciF8TWsyi9LeJWdOu7HJ4JoFquok7dv6tD9chf6wqpWz", + "snippet": { + "type": "superChatEvent", + "liveChatId": "oQZoCxfO7oUMBf703MdPwXb4c3EwgfI2Po4sL1oBaKbMelkCBiOS5UPOzE62l7hbC249v8wSTXcyEcVFHklmClse41wwiyO97uUyrt", + "authorChannelId": "UCZneZy3197k3ErGxVjKFZSg", + "publishedAt": "2018-01-01T00:00:00.000Z", + "hasDisplayContent": true, + "displayMessage": "¥200 from A: \"888\"", + "superChatDetails": { + "amountMicros": "200000000", + "currency": "JPY", + "amountDisplayString": "¥200", + "userComment": "888", + "tier": 2 + } + }, + "authorDetails": { + "channelId": "UCZneZy3197k3ErGxVjKFZSg", + "channelUrl": "http://www.youtube.com/channel/UCZneZy3197k3ErGxVjKFZSg", + "displayName": "A", + "profileImageUrl": "https://yt3.ggpht.com/-Dvb6csYDHb9/AAAAAAAAAAI/AAAAAAAAAAA/Bvb6csYDHb9/s88-c-k-no-mo-rj-c0xffffff/photo.jpg", + "isVerified": false, + "isChatOwner": false, + "isChatSponsor": false, + "isChatModerator": false + } + } + ] +} diff --git a/tests/testdata/compatible/API_TextMessage.json b/tests/testdata/compatible/API_TextMessage.json new file mode 100644 index 0000000..856c783 --- /dev/null +++ b/tests/testdata/compatible/API_TextMessage.json @@ -0,0 +1,38 @@ +{ + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "items": [ + { + "authorDetails": { + "channelId": "UCZneZy3197k3ErGxVjKFZSg", + "channelUrl": "http://www.youtube.com/channel/UCaqLU1hcYAO_nbvsoyfMo_A", + "displayName": "A", + "isChatModerator": false, + "isChatOwner": false, + "isChatSponsor": false, + "isVerified": false, + "profileImageUrl": "https://yt3.ggpht.com/-Dvb6csYDHb9/AAAAAAAAAAI/AAAAAAAAAAA/Bvb6csYDHb9/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "etag": "\"hiK4na4zECFv5N13lVUvtaHTVTYcbPrr1271dnlDcS75W128nqkZlTv\"", + "id": "LCC.Ck7NAO9vS2nHct38SwUnWfkQy5kxNdHJqrL0dYOb3izTAHB74jBnWRccAia8DURhN1h8oT79FH94gMnQ2OTfv5BPev1huOtEfAHjciF8TWsyi9LeJWdOu7HJ4JoFquok7dv6tD9chf6wqpWz", + "kind": "youtube#liveChatMessage", + "snippet": { + "authorChannelId": "UCZneZy3197k3ErGxVjKFZSg", + "displayMessage": "888", + "hasDisplayContent": true, + "liveChatId": "oQZoCxfO7oUMBf703MdPwXb4c3EwgfI2Po4sL1oBaKbMelkCBiOS5UPOzE62l7hbC249v8wSTXcyEcVFHklmClse41wwiyO97uUyrt", + "publishedAt": "2018-01-01T00:00:00.000Z", + "textMessageDetails": { + "messageText": "888" + }, + "type": "textMessageEvent" + } + } + ], + "kind": "youtube#liveChatMessageListResponse", + "nextPageToken": "GPaimtHdmuUCIJi18ez1muUC", + "pageInfo": { + "resultsPerPage": 74, + "totalResults": 74 + }, + "pollingIntervalMillis": 7300 +} \ No newline at end of file diff --git a/tests/testdata/compatible/newSponsor.json b/tests/testdata/compatible/newSponsor.json new file mode 100644 index 0000000..a5bd8b4 --- /dev/null +++ b/tests/testdata/compatible/newSponsor.json @@ -0,0 +1,1857 @@ +{ + "timing": { + "info": { + "st": 100 + } + }, + "response": { + "responseContext": { + "serviceTrackingParams": [ + { + "service": "CSI", + "params": [ + { + "key": "GetLiveChat_rid", + "value": "0xcb7fb21ed3bebef5" + }, + { + "key": "c", + "value": "WEB" + }, + { + "key": "cver", + "value": "2.20191010.04.06" + }, + { + "key": "yt_li", + "value": "0" + } + ] + }, + { + "service": "GFEEDBACK", + "params": [ + { + "key": "e", + "value": "23735226,23735347,23744176,23748146,23793834,23794463,23794618,23804281,23826780,23832073,23836260,23836965,23837742,23837772,23837851,23837993,23839278,23839362,23840216,23842630,23842986,23843534,23845646,23847144,23847943,23848422,23848676,23848676,23849316,23850276,23850330,23851677,24630348,24650038,9405960,9449243,9471235" + }, + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "GUIDED_HELP", + "params": [ + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "ECATCHER", + "params": [ + { + "key": "client.name", + "value": "WEB" + }, + { + "key": "client.version", + "value": "2.20191009" + }, + { + "key": "innertube.build.changelist", + "value": "273830839" + }, + { + "key": "innertube.build.experiments.source_version", + "value": "274226231" + }, + { + "key": "innertube.build.label", + "value": "youtube.ytfe.innertube_20191008_4_RC2" + }, + { + "key": "innertube.build.timestamp", + "value": "1570659367" + }, + { + "key": "innertube.build.variants.checksum", + "value": "3a5ae9409679bfdefdb52058a1fd326c" + }, + { + "key": "innertube.run.job", + "value": "ytfe-innertube-replica-only.ytfe" + } + ] + } + ], + "webResponseContextExtensionData": { + "ytConfigData": { + "csn": "PvujXbH0OIazqQHXgJ64DQ", + "visitorData": "CgtaMnVPX1Q0WlNlOCi-9o_tBQ%3D%3D" + } + } + }, + "continuationContents": { + "liveChatContinuation": { + "continuations": [ + { + "invalidationContinuationData": { + "invalidationId": { + "objectSource": 1056, + "objectId": "Y2hhdH5VQ25SUVlIVG5STFNGMGNMSndNbmVkQ2d-NTIzNjc1OQ==", + "topic": "chat~UCnRQYHTnRLSF0cLJwMnedCg~5236759", + "subscribeToGcmTopics": true, + "protoCreationTimestampMs": "1571027775014" + }, + "timeoutMs": 10000, + "continuation": "0ofMyAPQARpuQ2t3U0lRb1lWVU51VWxGWlNGUnVVa3hUUmpCalRFcDNUVzVsWkVObkVnVXZiR2wyWlNvbkNoaFZRMjVTVVZsSVZHNVNURk5HTUdOTVNuZE5ibVZrUTJjU0MwMURNM2RWU2kxQ1dFNUZJQUklM0Qo9qKa0d2a5QIwADgAQAJKKQgAEAAYACAAKg5zdGF0aWNjaGVja3N1bToAQABKAFCpzNfD9prlAlgDUOS4msbxmuUCWOS4msbxmuUCaAGCAQIIAYgBAKABt4LZw_aa5QI%3D", + "clickTrackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + } + ], + "actions": [ + { + "addChatItemAction": { + "item": { + "liveChatLegacyPaidMessageRenderer": { + "id": "ChwKGkNQYWltdEhkbXVVQ0Zhakl3UW9kaGgwS2RR", + "timestampUsec": "1571021092458870", + "eventText": { + "runs": [ + { + "text": "新規メンバー" + } + ] + }, + "detailText": { + "simpleText": "ようこそ、カチュア さん!" + }, + "authorName": { + "simpleText": "カチュア" + }, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-ASF61F1_1Ks/AAAAAAAAAAI/AAAAAAAAAAA/ybruJcnkZrg/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-ASF61F1_1Ks/AAAAAAAAAAI/AAAAAAAAAAA/ybruJcnkZrg/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "authorExternalChannelId": "UCWzeZdxsrvJ_xQc6ykGg8Pg", + "contextMenuEndpoint": { + "clickTrackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8", + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "liveChatItemContextMenuEndpoint": { + "params": "Q2g0S0hBb2FRMUJoYVcxMFNHUnRkVlZEUm1GcVNYZFJiMlJvYURCTFpGRVFBQnBNRWlFS0dGVkRibEpSV1VoVWJsSk1VMFl3WTB4S2QwMXVaV1JEWnhJRkwyeHBkbVVxSndvWVZVTnVVbEZaU0ZSdVVreFRSakJqVEVwM1RXNWxaRU5uRWd0TlF6TjNWVW90UWxoT1JTQUNLQUV5R2dvWVZVTlhlbVZhWkhoemNuWktYM2hSWXpaNWEwZG5PRkJu" + } + }, + "contextMenuAccessibility": { + "accessibilityData": { + "label": "コメントの操作" + } + } + } + } + } + }, + { + "addChatItemAction": { + "item": { + "liveChatViewerEngagementMessageRenderer": { + "id": "CioKKENPTU1VTklUWV9HVUlERUxJTkVTX1ZFTTE1NzEwMjc3NzQ5OTM0NTM%3D", + "timestampUsec": "1571027774993499", + "icon": { + "iconType": "YOUTUBE_ROUND" + }, + "message": { + "runs": [ + { + "text": "チャットへようこそ!ご自身のプライバシーを守るとともに、YouTube のコミュニティ ガイドラインを遵守することを忘れないでください。" + } + ] + }, + "actionButton": { + "buttonRenderer": { + "style": "STYLE_BLUE_TEXT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "text": { + "simpleText": "詳細" + }, + "navigationEndpoint": { + "clickTrackingParams": "CA0Q8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "commandMetadata": { + "webCommandMetadata": { + "url": "//support.google.com/youtube/answer/2853856?hl=ja#safe", + "rootVe": 83769 + } + }, + "urlEndpoint": { + "url": "//support.google.com/youtube/answer/2853856?hl=ja#safe", + "target": "TARGET_NEW_WINDOW" + } + }, + "accessibility": { + "label": "詳細" + }, + "trackingParams": "CA0Q8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "accessibilityData": { + "accessibilityData": { + "label": "詳細" + } + } + } + } + } + } + } + } + ], + "actionPanel": { + "liveChatMessageInputRenderer": { + "inputField": { + "liveChatTextInputFieldRenderer": { + "placeholder": { + "runs": [ + { + "text": "メッセージを入力..." + } + ] + }, + "maxCharacterLimit": 200, + "emojiCharacterCount": 10 + } + }, + "sendButton": { + "buttonRenderer": { + "icon": { + "iconType": "SEND" + }, + "accessibility": { + "label": "送信" + }, + "trackingParams": "CAwQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=" + } + }, + "pickers": [ + { + "emojiPickerRenderer": { + "id": "emoji", + "categories": [ + { + "emojiPickerCategoryRenderer": { + "categoryId": "people", + "title": { + "simpleText": "人" + }, + "emojiIds": [ + "😀", + "😁", + "😂", + "🤣", + "😃", + "😄", + "😅", + "😆", + "😉", + "😊", + "😋", + "😎", + "😍", + "😘", + "😗", + "😙", + "😚", + "☺", + "🙂", + "🤗", + "🤔", + "😐", + "😑", + "😶", + "🙄", + "😏", + "😣", + "😥", + "😮", + "🤐", + "😯", + "😪", + "😫", + "😴", + "😌", + "🤓", + "😛", + "😜", + "😝", + "🤤", + "😒", + "😓", + "😔", + "😕", + "🙃", + "🤑", + "😲", + "☹", + "🙁", + "😖", + "😞", + "😟", + "😤", + "😢", + "😭", + "😦", + "😧", + "😨", + "😩", + "😬", + "😰", + "😱", + "😳", + "😵", + "😡", + "😠", + "😇", + "🤠", + "🤡", + "🤥", + "😷", + "🤒", + "🤕", + "🤢", + "🤧", + "😈", + "👿", + "👹", + "👺", + "💀", + "👻", + "👽", + "🤖", + "💩", + "😺", + "😸", + "😹", + "😻", + "😼", + "😽", + "🙀", + "😿", + "😾", + "👦", + "👧", + "👨", + "👩", + "👴", + "👵", + "👶", + "👼", + "👮", + "🕵", + "💂", + "👷", + "👳", + "👱", + "🎅", + "🤶", + "👸", + "🤴", + "👰", + "🤵", + "🤰", + "👲", + "🙍", + "🙎", + "🙅", + "🙆", + "💁", + "🙋", + "🙇", + "🤦", + "🤷", + "💆", + "💇", + "🚶", + "🏃", + "💃", + "🕺", + "👯", + "🗣", + "👤", + "👥", + "👫", + "👬", + "👭", + "💏", + "👨‍❤️‍💋‍👨", + "👩‍❤️‍💋‍👩", + "💑", + "👨‍❤️‍👨", + "👩‍❤️‍👩", + "👪", + "👨‍👩‍👧", + "👨‍👩‍👧‍👦", + "👨‍👩‍👦‍👦", + "👨‍👩‍👧‍👧", + "👨‍👨‍👦", + "👨‍👨‍👧", + "👨‍👨‍👧‍👦", + "👨‍👨‍👦‍👦", + "👨‍👨‍👧‍👧", + "👩‍👩‍👦", + "👩‍👩‍👧", + "👩‍👩‍👧‍👦", + "👩‍👩‍👦‍👦", + "👩‍👩‍👧‍👧", + "💪", + "🤳", + "👈", + "👉", + "☝", + "👆", + "🖕", + "👇", + "✌", + "🤞", + "🖖", + "🤘", + "🤙", + "🖐", + "✋", + "👌", + "👍", + "👎", + "✊", + "👊", + "🤛", + "🤜", + "🤚", + "👋", + "👏", + "✍", + "👐", + "🙌", + "🙏", + "🤝", + "💅", + "👂", + "👃", + "👣", + "👀", + "👁", + "👅", + "👄", + "💋", + "💤", + "👓", + "🕶", + "👔", + "👕", + "👖", + "👗", + "👘", + "👙", + "👚", + "👛", + "👜", + "👝", + "🎒", + "👞", + "👟", + "👠", + "👡", + "👢", + "👑", + "👒", + "🎩", + "🎓", + "⛑", + "💄", + "💍", + "🌂", + "💼" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "nature", + "title": { + "simpleText": "自然" + }, + "emojiIds": [ + "🙈", + "🙉", + "🙊", + "💦", + "💨", + "🐵", + "🐒", + "🦍", + "🐶", + "🐕", + "🐩", + "🐺", + "🦊", + "🐱", + "🐈", + "🦁", + "🐯", + "🐅", + "🐆", + "🐴", + "🐎", + "🦌", + "🦄", + "🐮", + "🐂", + "🐃", + "🐄", + "🐷", + "🐖", + "🐗", + "🐽", + "🐏", + "🐑", + "🐐", + "🐪", + "🐫", + "🐘", + "🦏", + "🐭", + "🐁", + "🐀", + "🐹", + "🐰", + "🐇", + "🐿", + "🦇", + "🐻", + "🐨", + "🐼", + "🐾", + "🦃", + "🐔", + "🐓", + "🐣", + "🐤", + "🐥", + "🐦", + "🐧", + "🕊", + "🦅", + "🦆", + "🦉", + "🐸", + "🐊", + "🐢", + "🦎", + "🐍", + "🐲", + "🐉", + "🐳", + "🐋", + "🐬", + "🐟", + "🐠", + "🐡", + "🦈", + "🐙", + "🐚", + "🦀", + "🦐", + "🦑", + "🦋", + "🐌", + "🐛", + "🐜", + "🐝", + "🐞", + "🕷", + "🕸", + "🦂", + "💐", + "🌸", + "🏵", + "🌹", + "🥀", + "🌺", + "🌻", + "🌼", + "🌷", + "🌱", + "🌲", + "🌳", + "🌴", + "🌵", + "🌾", + "🌿", + "☘", + "🍀", + "🍁", + "🍂", + "🍃", + "🍄", + "🌰", + "🌍", + "🌎", + "🌏", + "🌑", + "🌒", + "🌓", + "🌔", + "🌕", + "🌖", + "🌗", + "🌘", + "🌙", + "🌚", + "🌛", + "🌜", + "☀", + "🌝", + "🌞", + "⭐", + "🌟", + "☁", + "⛅", + "⛈", + "🌤", + "🌥", + "🌦", + "🌧", + "🌨", + "🌩", + "🌪", + "🌫", + "🌬", + "☂", + "☔", + "⚡", + "❄", + "☃", + "⛄", + "☄", + "🔥", + "💧", + "🌊", + "🎃", + "🎄", + "✨", + "🎋", + "🎍" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "food", + "title": { + "simpleText": "食べ物" + }, + "emojiIds": [ + "🍇", + "🍈", + "🍉", + "🍊", + "🍋", + "🍌", + "🍍", + "🍎", + "🍏", + "🍐", + "🍑", + "🍒", + "🍓", + "🥝", + "🍅", + "🥑", + "🍆", + "🥔", + "🥕", + "🌽", + "🌶", + "🥒", + "🥜", + "🍞", + "🥐", + "🥖", + "🥞", + "🧀", + "🍖", + "🍗", + "🥓", + "🍔", + "🍟", + "🍕", + "🌭", + "🌮", + "🌯", + "🥙", + "🥚", + "🍳", + "🥘", + "🍲", + "🥗", + "🍿", + "🍱", + "🍘", + "🍙", + "🍚", + "🍛", + "🍜", + "🍝", + "🍠", + "🍢", + "🍣", + "🍤", + "🍥", + "🍡", + "🍦", + "🍧", + "🍨", + "🍩", + "🍪", + "🎂", + "🍰", + "🍫", + "🍬", + "🍭", + "🍮", + "🍯", + "🍼", + "🥛", + "☕", + "🍵", + "🍶", + "🍾", + "🍷", + "🍸", + "🍹", + "🍺", + "🍻", + "🥂", + "🥃", + "🍽", + "🍴", + "🥄" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "activities", + "title": { + "simpleText": "アクティビティ" + }, + "emojiIds": [ + "👾", + "🕴", + "🤺", + "🏇", + "⛷", + "🏂", + "🏌", + "🏄", + "🚣", + "🏊", + "⛹", + "🏋", + "🚴", + "🚵", + "🤸", + "🤼", + "🤽", + "🤾", + "🤹", + "🎪", + "🎭", + "🎨", + "🎰", + "🛀", + "🎗", + "🎟", + "🎫", + "🎖", + "🏆", + "🏅", + "🥇", + "🥈", + "🥉", + "⚽", + "⚾", + "🏀", + "🏐", + "🏈", + "🏉", + "🎾", + "🎱", + "🎳", + "🏏", + "🏑", + "🏒", + "🏓", + "🏸", + "🥊", + "🥋", + "🥅", + "🎯", + "⛳", + "⛸", + "🎣", + "🎽", + "🎿", + "🎮", + "🎲", + "🎼", + "🎤", + "🎧", + "🎷", + "🎸", + "🎹", + "🎺", + "🎻", + "🥁", + "🎬", + "🏹" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "travel", + "title": { + "simpleText": "旅行" + }, + "emojiIds": [ + "🏎", + "🏍", + "🗾", + "🏔", + "⛰", + "🌋", + "🗻", + "🏕", + "🏖", + "🏜", + "🏝", + "🏞", + "🏟", + "🏛", + "🏗", + "🏘", + "🏙", + "🏚", + "🏠", + "🏡", + "🏢", + "🏣", + "🏤", + "🏥", + "🏦", + "🏨", + "🏩", + "🏪", + "🏫", + "🏬", + "🏭", + "🏯", + "🏰", + "💒", + "🗼", + "🗽", + "⛪", + "🕌", + "🕍", + "⛩", + "🕋", + "⛲", + "⛺", + "🌁", + "🌃", + "🌄", + "🌅", + "🌆", + "🌇", + "🌉", + "🌌", + "🎠", + "🎡", + "🎢", + "🚂", + "🚃", + "🚄", + "🚅", + "🚆", + "🚇", + "🚈", + "🚉", + "🚊", + "🚝", + "🚞", + "🚋", + "🚌", + "🚍", + "🚎", + "🚐", + "🚑", + "🚒", + "🚓", + "🚔", + "🚕", + "🚖", + "🚗", + "🚘", + "🚙", + "🚚", + "🚛", + "🚜", + "🚲", + "🛴", + "🛵", + "🚏", + "🛣", + "🛤", + "⛽", + "🚨", + "🚥", + "🚦", + "🚧", + "⚓", + "⛵", + "🛶", + "🚤", + "🛳", + "⛴", + "🛥", + "🚢", + "✈", + "🛩", + "🛫", + "🛬", + "💺", + "🚁", + "🚟", + "🚠", + "🚡", + "🚀", + "🛰", + "🌠", + "🌈", + "🎆", + "🎇", + "🎑", + "🏁" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "objects", + "title": { + "simpleText": "オブジェクト" + }, + "emojiIds": [ + "☠", + "💌", + "💣", + "🕳", + "🛍", + "📿", + "💎", + "🔪", + "🏺", + "🗺", + "💈", + "🖼", + "🛎", + "🚪", + "🛌", + "🛏", + "🛋", + "🚽", + "🚿", + "🛁", + "⌛", + "⏳", + "⌚", + "⏰", + "⏱", + "⏲", + "🕰", + "🌡", + "⛱", + "🎈", + "🎉", + "🎊", + "🎎", + "🎏", + "🎐", + "🎀", + "🎁", + "🕹", + "📯", + "🎙", + "🎚", + "🎛", + "📻", + "📱", + "📲", + "☎", + "📞", + "📟", + "📠", + "🔋", + "🔌", + "💻", + "🖥", + "🖨", + "⌨", + "🖱", + "🖲", + "💽", + "💾", + "💿", + "📀", + "🎥", + "🎞", + "📽", + "📺", + "📷", + "📸", + "📹", + "📼", + "🔍", + "🔎", + "🔬", + "🔭", + "📡", + "🕯", + "💡", + "🔦", + "🏮", + "📔", + "📕", + "📖", + "📗", + "📘", + "📙", + "📚", + "📓", + "📒", + "📃", + "📜", + "📄", + "📰", + "🗞", + "📑", + "🔖", + "🏷", + "💰", + "💴", + "💵", + "💶", + "💷", + "💸", + "💳", + "✉", + "📧", + "📨", + "📩", + "📤", + "📥", + "📦", + "📫", + "📪", + "📬", + "📭", + "📮", + "🗳", + "✏", + "✒", + "🖋", + "🖊", + "🖌", + "🖍", + "📝", + "📁", + "📂", + "🗂", + "📅", + "📆", + "🗒", + "🗓", + "📇", + "📈", + "📉", + "📊", + "📋", + "📌", + "📍", + "📎", + "🖇", + "📏", + "📐", + "✂", + "🗃", + "🗄", + "🗑", + "🔒", + "🔓", + "🔏", + "🔐", + "🔑", + "🗝", + "🔨", + "⛏", + "⚒", + "🛠", + "🗡", + "⚔", + "🔫", + "🛡", + "🔧", + "🔩", + "⚙", + "🗜", + "⚗", + "⚖", + "🔗", + "⛓", + "💉", + "💊", + "🚬", + "⚰", + "⚱", + "🗿", + "🛢", + "🔮", + "🛒", + "🚩", + "🎌", + "🏴", + "🏳" + ] + } + }, + { + "emojiPickerCategoryRenderer": { + "categoryId": "symbols", + "title": { + "simpleText": "記号" + }, + "emojiIds": [ + "❤", + "💛", + "💚", + "💙", + "💜", + "💔", + "❣", + "💕", + "💞", + "💓", + "💗", + "💖", + "💘", + "💝", + "💟", + "☮", + "✝", + "☪", + "🕉", + "☸", + "✡", + "🔯", + "🕎", + "☯", + "☦", + "🛐", + "⛎", + "♈", + "♉", + "♊", + "♋", + "♌", + "♍", + "♎", + "♏", + "♐", + "♑", + "♒", + "♓", + "⚛", + "☢", + "☣", + "📴", + "📳", + "✴", + "💮", + "⛔", + "📛", + "🚫", + "❌", + "⭕", + "💢", + "♨", + "🚷", + "🚯", + "🚳", + "🚱", + "🔞", + "📵", + "❗", + "❕", + "❓", + "❔", + "‼", + "⁉", + "💯", + "🔅", + "🔆", + "🔱", + "⚜", + "〽", + "⚠", + "🚸", + "🔰", + "♻", + "❇", + "✳", + "❎", + "✅", + "💠", + "🌀", + "➿", + "🌐", + "🏧", + "🛂", + "🛃", + "🛄", + "🛅", + "♿", + "🚭", + "🚾", + "🚰", + "🚹", + "🚺", + "🚼", + "🚻", + "🚮", + "🎦", + "📶", + "▶", + "⏸", + "⏯", + "⏹", + "⏺", + "⏭", + "⏮", + "⏩", + "⏪", + "🔀", + "🔁", + "🔂", + "◀", + "🔼", + "🔽", + "⏫", + "⏬", + "➡", + "⬅", + "⬆", + "⬇", + "↗", + "↘", + "↙", + "↖", + "↕", + "↔", + "🔄", + "↪", + "↩", + "⤴", + "⤵", + "🔣", + "🎵", + "🎶", + "〰", + "➰", + "✔", + "🔃", + "➕", + "➖", + "➗", + "✖", + "💲", + "💱", + "☑", + "🔘", + "⚪", + "⚫", + "🔴", + "🔵", + "🔸", + "🔹", + "🔶", + "🔷", + "🔺", + "▪", + "▫", + "⬛", + "⬜", + "🔻", + "◼", + "◻", + "◾", + "◽", + "🔲", + "🔳", + "🔈", + "🔉", + "🔊", + "🔇", + "📣", + "📢", + "🔔", + "🔕", + "🃏", + "🀄", + "♠", + "♣", + "♥", + "♦", + "🎴", + "💭", + "🗯", + "💬", + "🕐", + "🕑", + "🕒", + "🕓", + "🕔", + "🕕", + "🕖", + "🕗", + "🕘", + "🕙", + "🕚", + "🕛", + "🕜", + "🕝", + "🕞", + "🕟", + "🕠", + "🕡", + "🕢", + "🕣", + "🕤", + "🕥", + "🕦", + "🕧", + "👁‍🗨", + "🗨", + "⏏" + ] + } + } + ], + "categoryButtons": [ + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "people", + "icon": { + "iconType": "EMOJI_PEOPLE" + }, + "tooltip": "人", + "accessibility": { + "accessibilityData": { + "label": "人" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "nature", + "icon": { + "iconType": "EMOJI_NATURE" + }, + "tooltip": "自然", + "accessibility": { + "accessibilityData": { + "label": "自然" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "food", + "icon": { + "iconType": "EMOJI_FOOD" + }, + "tooltip": "食べ物", + "accessibility": { + "accessibilityData": { + "label": "食べ物" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "activities", + "icon": { + "iconType": "EMOJI_ACTIVITIES" + }, + "tooltip": "アクティビティ", + "accessibility": { + "accessibilityData": { + "label": "アクティビティ" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "travel", + "icon": { + "iconType": "EMOJI_TRAVEL" + }, + "tooltip": "旅行", + "accessibility": { + "accessibilityData": { + "label": "旅行" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "objects", + "icon": { + "iconType": "EMOJI_OBJECTS" + }, + "tooltip": "オブジェクト", + "accessibility": { + "accessibilityData": { + "label": "オブジェクト" + } + } + } + }, + { + "emojiPickerCategoryButtonRenderer": { + "categoryId": "symbols", + "icon": { + "iconType": "EMOJI_SYMBOLS" + }, + "tooltip": "記号", + "accessibility": { + "accessibilityData": { + "label": "記号" + } + } + } + } + ], + "searchPlaceholderText": { + "runs": [ + { + "text": "絵文字を検索" + } + ] + }, + "searchNoResultsText": { + "runs": [ + { + "text": "絵文字が見つかりませんでした" + } + ] + }, + "pickSkinToneText": { + "runs": [ + { + "text": "絵文字の肌の色を選択" + } + ] + }, + "trackingParams": "CAsQsrQCGAUiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "clearSearchLabel": "検索をクリア", + "skinToneGenericLabel": "汎用的な肌の色", + "skinToneLightLabel": "明るい肌の色", + "skinToneMediumLightLabel": "やや明るい肌の色", + "skinToneMediumLabel": "中間的な明るさの肌の色", + "skinToneMediumDarkLabel": "やや濃い肌の色", + "skinToneDarkLabel": "濃い肌の色" + } + } + ], + "pickerButtons": [ + { + "liveChatIconToggleButtonRenderer": { + "targetId": "emoji", + "icon": { + "iconType": "EMOJI" + }, + "tooltip": "絵文字", + "accessibility": { + "accessibilityData": { + "label": "絵文字" + } + }, + "toggledIcon": { + "iconType": "KEYBOARD" + }, + "trackingParams": "CAoQtIkEGAYiEwjUttXD9prlAhXZzEwCHYC0CPw=" + } + } + ], + "interactionMessage": { + "messageRenderer": { + "trackingParams": "CAgQljsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "button": { + "buttonRenderer": { + "style": "STYLE_DARK", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "text": { + "simpleText": "ログインしてチャットを始める" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAkQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "commandMetadata": { + "webCommandMetadata": { + "url": "https://accounts.google.com/ServiceLogin?service=youtube\u0026uilel=3\u0026passive=true\u0026continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26app%3Ddesktop%26hl%3Dja%26next%3Dhttps%253A%252F%252Fwww.youtube.com%252Flive_chat%253Fv%253DMC3wUJ-BXNE%2526is_popout%253D1\u0026hl=ja", + "rootVe": 83769 + } + }, + "signInEndpoint": { + "nextEndpoint": { + "clickTrackingParams": "CAkQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "commandMetadata": { + "webCommandMetadata": { + "url": "https://www.youtube.com/live_chat?v=MC3wUJ-BXNE\u0026is_popout=1", + "rootVe": 83769 + } + }, + "urlEndpoint": { + "url": "https://www.youtube.com/live_chat?v=MC3wUJ-BXNE\u0026is_popout=1" + } + } + } + }, + "accessibility": { + "label": "ログインしてチャットを始める" + }, + "trackingParams": "CAkQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=" + } + }, + "subtext": { + "messageSubtextRenderer": { + "text": { + "simpleText": "送信したすべてのメッセージが公開されます" + } + } + } + } + } + } + }, + "itemList": { + "liveChatItemListRenderer": { + "maxItemsToDisplay": 250, + "moreCommentsBelowButton": { + "buttonRenderer": { + "style": "STYLE_PRIMARY", + "icon": { + "iconType": "DOWN_ARROW" + }, + "trackingParams": "CAcQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "accessibilityData": { + "accessibilityData": { + "label": "さらに下のコメントを表示" + } + } + } + }, + "enablePauseChatKeyboardShortcuts": false + } + }, + "header": { + "liveChatHeaderRenderer": { + "overflowMenu": { + "menuRenderer": { + "items": [ + { + "menuServiceItemRenderer": { + "text": { + "runs": [ + { + "text": "参加者" + } + ] + }, + "icon": { + "iconType": "PERSON" + }, + "serviceEndpoint": { + "showLiveChatParticipantsEndpoint": { + "hack": true + } + }, + "trackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + }, + { + "menuServiceItemRenderer": { + "text": { + "runs": [ + { + "text": "タイムスタンプ表示切替" + } + ] + }, + "icon": { + "iconType": "ACCESS_TIME" + }, + "serviceEndpoint": { + "toggleLiveChatTimestampsEndpoint": { + "hack": true + } + }, + "trackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + }, + { + "menuNavigationItemRenderer": { + "text": { + "runs": [ + { + "text": "フィードバックを送信" + } + ] + }, + "icon": { + "iconType": "FEEDBACK" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8", + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "userFeedbackEndpoint": { + "hack": true, + "bucketIdentifier": "live_chat" + } + }, + "trackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + } + ], + "trackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8", + "accessibility": { + "accessibilityData": { + "label": "その他のオプション" + } + } + } + }, + "collapseButton": { + "buttonRenderer": { + "style": "STYLE_DEFAULT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "accessibility": { + "label": "チャットの展開 / 折りたたみ" + }, + "trackingParams": "CAYQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=" + } + }, + "viewSelector": { + "sortFilterSubMenuRenderer": { + "subMenuItems": [ + { + "title": "上位チャット", + "selected": false, + "continuation": { + "reloadContinuationData": { + "continuation": "0ofMyAOBAhrKAUNrd1NJUW9ZVlVOdVVsRlpTRlJ1VWt4VFJqQmpURXAzVFc1bFpFTm5FZ1V2YkdsMlpTb25DaGhWUTI1U1VWbElWRzVTVEZOR01HTk1TbmROYm1Wa1EyY1NDMDFETTNkVlNpMUNXRTVGR2tPcXVjRzlBVDBLTzJoMGRIQnpPaTh2ZDNkM0xubHZkWFIxWW1VdVkyOXRMMnhwZG1WZlkyaGhkRDkyUFUxRE0zZFZTaTFDV0U1RkptbHpYM0J2Y0c5MWREMHhJQUklM0QwAUopCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoAUKnM18P2muUCWANoBIIBAggE", + "clickTrackingParams": "CAUQxqYCIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + }, + "accessibility": { + "accessibilityData": { + "label": "上位チャット" + } + }, + "subtitle": "一部のメッセージ(不適切な可能性があるものなど)を非表示にします" + }, + { + "title": "チャット", + "selected": true, + "continuation": { + "reloadContinuationData": { + "continuation": "0ofMyAOBAhrKAUNrd1NJUW9ZVlVOdVVsRlpTRlJ1VWt4VFJqQmpURXAzVFc1bFpFTm5FZ1V2YkdsMlpTb25DaGhWUTI1U1VWbElWRzVTVEZOR01HTk1TbmROYm1Wa1EyY1NDMDFETTNkVlNpMUNXRTVGR2tPcXVjRzlBVDBLTzJoMGRIQnpPaTh2ZDNkM0xubHZkWFIxWW1VdVkyOXRMMnhwZG1WZlkyaGhkRDkyUFUxRE0zZFZTaTFDV0U1RkptbHpYM0J2Y0c5MWREMHhJQUklM0QwAUopCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoAUKnM18P2muUCWANoAYIBAggB", + "clickTrackingParams": "CAQQxqYCIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + }, + "accessibility": { + "accessibilityData": { + "label": "チャット" + } + }, + "subtitle": "すべてのメッセージが表示されます" + } + ], + "accessibility": { + "accessibilityData": { + "label": "チャットのモードの選択" + } + }, + "trackingParams": "CAMQgdoEIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + } + } + } + }, + "ticker": { + "liveChatTickerRenderer": { + "sentinel": true + } + }, + "trackingParams": "CAEQl98BIhMI1LbVw_aa5QIV2cxMAh2AtAj8", + "participantsList": { + "liveChatParticipantsListRenderer": { + "title": { + "runs": [ + { + "text": "参加者" + } + ] + }, + "backButton": { + "buttonRenderer": { + "icon": { + "iconType": "BACK" + }, + "trackingParams": "CAIQ8FsiEwjUttXD9prlAhXZzEwCHYC0CPw=", + "accessibilityData": { + "accessibilityData": { + "label": "戻る" + } + } + } + }, + "participants": [ + { + "liveChatParticipantRenderer": { + "authorName": { + "simpleText": "相羽ういは〖Aiba Uiha〗にじさんじ所属" + }, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-oKuaFgPf7a8/AAAAAAAAAAI/AAAAAAAAAAA/6oDCrDvXdsc/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-oKuaFgPf7a8/AAAAAAAAAAI/AAAAAAAAAAA/6oDCrDvXdsc/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "authorBadges": [ + { + "liveChatAuthorBadgeRenderer": { + "icon": { + "iconType": "OWNER" + }, + "tooltip": "所有者", + "accessibility": { + "accessibilityData": { + "label": "所有者" + } + } + } + } + ] + } + } + ] + } + }, + "clientMessages": { + "reconnectMessage": { + "runs": [ + { + "text": "チャットが切断されました。再接続するまでしばらくお待ちください。" + } + ] + }, + "unableToReconnectMessage": { + "runs": [ + { + "text": "チャットに接続できません。しばらくしてからもう一度お試しください。" + } + ] + }, + "fatalError": { + "runs": [ + { + "text": "チャットに接続できません。しばらくしてからもう一度お試しください。" + } + ] + }, + "reconnectedMessage": { + "runs": [ + { + "text": "接続しました。" + } + ] + }, + "genericError": { + "runs": [ + { + "text": "エラーが発生しました。もう一度お試しください。" + } + ] + } + } + } + }, + "trackingParams": "CAAQ0b4BIhMI1LbVw_aa5QIV2cxMAh2AtAj8" + }, + "url": "\/live_chat\/get_live_chat?continuation=0ofMyAOBAhrKAUNrd1NJUW9ZVlVOdVVsRlpTRlJ1VWt4VFJqQmpURXAzVFc1bFpFTm5FZ1V2YkdsMlpTb25DaGhWUTI1U1VWbElWRzVTVEZOR01HTk1TbmROYm1Wa1EyY1NDMDFETTNkVlNpMUNXRTVGR2tPcXVjRzlBVDBLTzJoMGRIQnpPaTh2ZDNkM0xubHZkWFIxWW1VdVkyOXRMMnhwZG1WZlkyaGhkRDkyUFUxRE0zZFZTaTFDV0U1RkptbHpYM0J2Y0c5MWREMHhJQUklM0QwAUopCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoAUPOaw8P2muUCWANoAYIBAggB", + "csn": "PvujXbH0OIazqQHXgJ64DQ", + "endpoint": { + "commandMetadata": { + "webCommandMetadata": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAOBAhrKAUNrd1NJUW9ZVlVOdVVsRlpTRlJ1VWt4VFJqQmpURXAzVFc1bFpFTm5FZ1V2YkdsMlpTb25DaGhWUTI1U1VWbElWRzVTVEZOR01HTk1TbmROYm1Wa1EyY1NDMDFETTNkVlNpMUNXRTVGR2tPcXVjRzlBVDBLTzJoMGRIQnpPaTh2ZDNkM0xubHZkWFIxWW1VdVkyOXRMMnhwZG1WZlkyaGhkRDkyUFUxRE0zZFZTaTFDV0U1RkptbHpYM0J2Y0c5MWREMHhJQUklM0QwAUopCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoAUPOaw8P2muUCWANoAYIBAggB", + "rootVe": 83769 + } + }, + "urlEndpoint": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAOBAhrKAUNrd1NJUW9ZVlVOdVVsRlpTRlJ1VWt4VFJqQmpURXAzVFc1bFpFTm5FZ1V2YkdsMlpTb25DaGhWUTI1U1VWbElWRzVTVEZOR01HTk1TbmROYm1Wa1EyY1NDMDFETTNkVlNpMUNXRTVGR2tPcXVjRzlBVDBLTzJoMGRIQnpPaTh2ZDNkM0xubHZkWFIxWW1VdVkyOXRMMnhwZG1WZlkyaGhkRDkyUFUxRE0zZFZTaTFDV0U1RkptbHpYM0J2Y0c5MWREMHhJQUklM0QwAUopCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoAUPOaw8P2muUCWANoAYIBAggB" + } + }, + "xsrf_token": "QUFFLUhqbTBwS2VJSERSSGl3cG5BSFhqMERYeGxrRk1sd3xBQ3Jtc0tudnVRN3Z4cDJlcWpMelVTTjZvVE5lX010aWtfRGNYeGJvUTlNSHNtRHcwVDNLLXpfZlhyTFJIZ1NXVFBnYjA3dHJQRGViaDFMTWloelhwN3RzV3JNa2hhUlJIa09nb0JWaURISDJIUjlFVlRUT3ppemsySjExbzdEM1hudG1abHV6b29KellpaHdsVzROSXE1U0ZSMzhWSzhjQWc=" +} \ No newline at end of file diff --git a/tests/testdata/compatible/superchat.json b/tests/testdata/compatible/superchat.json new file mode 100644 index 0000000..c035589 --- /dev/null +++ b/tests/testdata/compatible/superchat.json @@ -0,0 +1,282 @@ +{ + "xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=", + "timing": { + "info": { + "st": 148 + } + }, + "endpoint": { + "commandMetadata": { + "webCommandMetadata": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D" + } + }, + "urlEndpoint": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D" + } + }, + "url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D", + "csn": "n2STXd2iKZr2gAOt9qvgCg", + "response": { + "responseContext": { + "serviceTrackingParams": [ + { + "service": "CSI", + "params": [ + { + "key": "GetLiveChat_rid", + "value": "0x9290108c05344647" + }, + { + "key": "c", + "value": "WEB" + }, + { + "key": "cver", + "value": "2.20191001.04.00" + }, + { + "key": "yt_li", + "value": "0" + } + ] + }, + { + "service": "GFEEDBACK", + "params": [ + { + "key": "e", + "value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360" + }, + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "GUIDED_HELP", + "params": [ + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "ECATCHER", + "params": [ + { + "key": "client.name", + "value": "WEB" + }, + { + "key": "client.version", + "value": "2.20191001" + }, + { + "key": "innertube.build.changelist", + "value": "272006966" + }, + { + "key": "innertube.build.experiments.source_version", + "value": "272166268" + }, + { + "key": "innertube.build.label", + "value": "youtube.ytfe.innertube_20190930_5_RC0" + }, + { + "key": "innertube.build.timestamp", + "value": "1569863426" + }, + { + "key": "innertube.build.variants.checksum", + "value": "1a800c1a2396906f1cbb7f670d43b6f5" + }, + { + "key": "innertube.run.job", + "value": "ytfe-innertube-replica-only.ytfe" + } + ] + } + ], + "webResponseContextExtensionData": { + "ytConfigData": { + "csn": "n2STXd2iKZr2gAOt9qvgCg", + "visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D" + } + } + }, + "continuationContents": { + "liveChatContinuation": { + "continuations": [ + { + "timedContinuationData": { + "timeoutMs": 8860, + "continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D" + } + } + ], + "actions": [ + { + "addChatItemAction": { + "item": { + "liveChatPaidMessageRenderer": { + "id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn", + "timestampUsec": "1569940638420061", + "authorName": { + "simpleText": "九十九 万" + }, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "purchaseAmountText": { + "simpleText": "¥846" + }, + "message": { + "runs": [ + { + "text": "ボルガ博士お許しください代" + } + ] + }, + "headerBackgroundColor": 4278239141, + "headerTextColor": 4278190080, + "bodyBackgroundColor": 4280150454, + "bodyTextColor": 4278190080, + "authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA", + "authorNameTextColor": 2315255808, + "contextMenuEndpoint": { + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "liveChatItemContextMenuEndpoint": { + "params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA==" + } + }, + "timestampColor": 2147483648, + "contextMenuAccessibility": { + "accessibilityData": { + "label": "コメントの操作" + } + } + } + } + } + }, + { + "addLiveChatTickerItemAction": { + "item": { + "liveChatTickerPaidMessageItemRenderer": { + "id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn", + "amount": { + "simpleText": "¥846" + }, + "amountTextColor": 4278190080, + "startBackgroundColor": 4280150454, + "endBackgroundColor": 4278239141, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "durationSec": 120, + "showItemEndpoint": { + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "showLiveChatItemEndpoint": { + "renderer": { + "liveChatPaidMessageRenderer": { + "id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn", + "timestampUsec": "1569940638420061", + "authorName": { + "simpleText": "九十九 万" + }, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "purchaseAmountText": { + "simpleText": "¥846" + }, + "message": { + "runs": [ + { + "text": "ボルガ博士お許しください代" + } + ] + }, + "headerBackgroundColor": 4278239141, + "headerTextColor": 4278190080, + "bodyBackgroundColor": 4280150454, + "bodyTextColor": 4278190080, + "authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA", + "authorNameTextColor": 2315255808, + "contextMenuEndpoint": { + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "liveChatItemContextMenuEndpoint": { + "params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA==" + } + }, + "timestampColor": 2147483648, + "contextMenuAccessibility": { + "accessibilityData": { + "label": "コメントの操作" + } + } + } + } + } + }, + "authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA", + "fullDurationSec": 120 + } + }, + "durationSec": "120" + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/testdata/compatible/supersticker.json b/tests/testdata/compatible/supersticker.json new file mode 100644 index 0000000..cebccdb --- /dev/null +++ b/tests/testdata/compatible/supersticker.json @@ -0,0 +1,197 @@ +{ + "xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=", + "timing": { + "info": { + "st": 148 + } + }, + "endpoint": { + "commandMetadata": { + "webCommandMetadata": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D" + } + }, + "urlEndpoint": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D" + } + }, + "url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D", + "csn": "n2STXd2iKZr2gAOt9qvgCg", + "response": { + "responseContext": { + "serviceTrackingParams": [ + { + "service": "CSI", + "params": [ + { + "key": "GetLiveChat_rid", + "value": "0x9290108c05344647" + }, + { + "key": "c", + "value": "WEB" + }, + { + "key": "cver", + "value": "2.20191001.04.00" + }, + { + "key": "yt_li", + "value": "0" + } + ] + }, + { + "service": "GFEEDBACK", + "params": [ + { + "key": "e", + "value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360" + }, + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "GUIDED_HELP", + "params": [ + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "ECATCHER", + "params": [ + { + "key": "client.name", + "value": "WEB" + }, + { + "key": "client.version", + "value": "2.20191001" + }, + { + "key": "innertube.build.changelist", + "value": "272006966" + }, + { + "key": "innertube.build.experiments.source_version", + "value": "272166268" + }, + { + "key": "innertube.build.label", + "value": "youtube.ytfe.innertube_20190930_5_RC0" + }, + { + "key": "innertube.build.timestamp", + "value": "1569863426" + }, + { + "key": "innertube.build.variants.checksum", + "value": "1a800c1a2396906f1cbb7f670d43b6f5" + }, + { + "key": "innertube.run.job", + "value": "ytfe-innertube-replica-only.ytfe" + } + ] + } + ], + "webResponseContextExtensionData": { + "ytConfigData": { + "csn": "n2STXd2iKZr2gAOt9qvgCg", + "visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D" + } + } + }, + "continuationContents": { + "liveChatContinuation": { + "continuations": [ + { + "timedContinuationData": { + "timeoutMs": 8860, + "continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D" + } + } + ], + "actions": [ + { + "addChatItemAction": { + "item": { + "liveChatPaidStickerRenderer": { + "id": "ChwKGkNQX2Qzb2pUcU9VQ0ZRdnVXQW9kaTNJS3NB", + "contextMenuEndpoint": { + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "liveChatItemContextMenuEndpoint": { + "params": "Q2g0S0hBb2FRMUJmWkROdmFsUnhUMVZEUmxGMmRWZEJiMlJwTTBsTGMwRVFBQm80Q2cwS0N6VlVUSE42U0hNd2QxYzBLaWNLR0ZWRFdGSnNTVXN6UTNkZlZFcEpVVU0xYTFOS1NsRk5aeElMTlZSTWMzcEljekIzVnpRZ0FpZ0JNaG9LR0ZWRFRHOXJPVWQ0WVRGYU5rWTVWV3d5WVV0MlRFWkdadyUzRCUzRA==" + } + }, + "contextMenuAccessibility": { + "accessibilityData": { + "label": "コメントの操作" + } + }, + "timestampUsec": "1571499325098699", + "authorPhoto": { + "thumbnails": [ + { + "url": "https: //yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "authorName": { + "simpleText": "りお" + }, + "authorExternalChannelId": "UCLok9Gxa1Z6F9Ul2aKvLFFg", + "sticker": { + "thumbnails": [ + { + "url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s72-rwa", + "width": 72, + "height": 72 + }, + { + "url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s144-rwa", + "width": 144, + "height": 144 + } + ], + "accessibility": { + "accessibilityData": { + "label": "気付いてもらえるように人差し指を上げたり下げたりしている柴犬" + } + } + }, + "moneyChipBackgroundColor": 4278248959, + "moneyChipTextColor": 4278190080, + "purchaseAmountText": { + "simpleText": "¥200" + }, + "stickerDisplayWidth": 72, + "stickerDisplayHeight": 72, + "backgroundColor": 4278237396, + "authorNameTextColor": 3003121664 + } + } + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/testdata/compatible/textmessage.json b/tests/testdata/compatible/textmessage.json new file mode 100644 index 0000000..6ae9f69 --- /dev/null +++ b/tests/testdata/compatible/textmessage.json @@ -0,0 +1,177 @@ +{ + "response": { + "responseContext": { + "serviceTrackingParams": [ + { + "service": "CSI", + "params": [ + { + "key": "GetLiveChat_rid", + "value": "0x3eff0db28fc39bbe" + }, + { + "key": "c", + "value": "WEB" + }, + { + "key": "cver", + "value": "2.20190920.05.01" + }, + { + "key": "yt_li", + "value": "0" + } + ] + }, + { + "service": "GFEEDBACK", + "params": [ + { + "key": "e", + "value": "23744176,23748146,23788851,23788875,23793834,23804281,23807353,23808952,23828082,23828243,23829333,23832544,23834418,23834656,23835020,23836434,23836965,23837742,23837772,23837993,23838301,23838576,23838576,23838742,23839360,23840216,23841655,23842986,23843288,23843533,23843743,23844780,24630231,9425362,9449243,9466592,9469037,9471235,9474358" + }, + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "GUIDED_HELP", + "params": [ + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "ECATCHER", + "params": [ + { + "key": "client.name", + "value": "WEB" + }, + { + "key": "client.version", + "value": "2.20190920" + }, + { + "key": "innertube.build.changelist", + "value": "270169303" + }, + { + "key": "innertube.build.experiments.source_version", + "value": "270377311" + }, + { + "key": "innertube.build.label", + "value": "youtube.ytfe.innertube_20190919_5_RC1" + }, + { + "key": "innertube.build.timestamp", + "value": "1568942548" + }, + { + "key": "innertube.build.variants.checksum", + "value": "392d499f55b5e2c240adde58886a8143" + }, + { + "key": "innertube.run.job", + "value": "ytfe-innertube-replica-only.ytfe" + } + ] + } + ], + "webResponseContextExtensionData": { + "ytConfigData": { + "csn": "n96GXabRGouFlQTigY2YDg", + "visitorData": "CgtKUldQeGJJRXhkcyifvZvsBQ%3D%3D" + } + } + }, + "continuationContents": { + "liveChatContinuation": { + "continuations": [ + { + "timedContinuationData": { + "timeoutMs": 5041, + "continuation": "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCj7hLmSs-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1DsuuGSs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKAB7KDVk7Pj5AI%3D" + } + } + ], + "actions": [ + { + "addChatItemAction": { + "item": { + "liveChatTextMessageRenderer": { + "message": { + "runs": [ + { + "text": "text" + } + ] + }, + "authorName": { + "simpleText": "name" + }, + "authorPhoto": { + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s32-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 32, + "height": 32 + }, + { + "url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s64-c-k-no-mo-rj-c0xffffff/photo.jpg", + "width": 64, + "height": 64 + } + ] + }, + "contextMenuEndpoint": { + "commandMetadata": { + "webCommandMetadata": { + "ignoreNavigation": true + } + }, + "liveChatItemContextMenuEndpoint": { + "params": "Q2pzS09Rb2FRMHRQTkhRMVEzbzBMVkZEUmxweGRrUlJiMlJZV0d0QlVHY1NHME5PYm5OdlVESm1OQzFSUTBaUmRsTlhRVzlrVVZGclJUTlJNeEFBR2pnS0RRb0xhelJMZDBsWFQwdERkalFxSndvWVZVTnZTWEZ1TVZvMWFYaERXbmRqTUVSWFNqZHlTME5uRWd0ck5FdDNTVmRQUzBOMk5DQUNLQUV5R2dvWVZVTnlOVGxXVlY5amRtWnlkVEF0YW1GeWNtUk1NMDEz" + } + }, + "id": "CjkKGkNLTzR0NUN6NC1RQ0ZacXZEUW9kWFhrQVBnEhtDTm5zb1AyZjQtUUNGUXZTV0FvZFFRa0UzUTM%3D", + "timestampUsec": "1569119896722467", + "authorExternalChannelId": "UCr59VU_cvfru0-jarrdL3Mw", + "contextMenuAccessibility": { + "accessibilityData": { + "label": "コメントの操作" + } + } + } + }, + "clientId": "CNnsoP2f4-QCFQvSWAodQQkE3Q3" + } + } + ] + } + } + }, + "endpoint": { + "commandMetadata": { + "webCommandMetadata": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D" + } + }, + "urlEndpoint": { + "url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D" + } + }, + "csn": "n96GXabRGouFlQTigY2YDg", + "xsrf_token": "QUFFLUhqbHNNWTF3NFJqc2h3cGE1NE9FWGdaWk5mRlVhUXxBQ3Jtc0tuTWhZNFcyTW1iZnA3ZnFTYUFudVFEUVE0cnFEOVBGcEU1MEh0Zlh4bll1amVmRl9OMkxZV3pKV1ZSbExBeDctTl95NGtBVnJZdlNxeS1KdWVNempEN2N6MHhaU1laV3hnVkZPeHp1OHVDTGVFSGUyOGduT0szbDV5N05LYUZTdzdoTDRwV1VJWndaVjdQVGRjNWVpR0YwUXgtZXc=", + "url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D", + "timing": { + "info": { + "st": 81 + } + } +} \ No newline at end of file diff --git a/tests/testdata/finished_live.json b/tests/testdata/finished_live.json new file mode 100644 index 0000000..0300acc --- /dev/null +++ b/tests/testdata/finished_live.json @@ -0,0 +1 @@ +{"csn":"zeiIXfXHJYOA1d8Pyuaw4A4","response":{"responseContext":{"serviceTrackingParams":[{"service":"CSI","params":[{"key":"GetLiveChat_rid","value":"0x96761cd683987638"},{"key":"c","value":"WEB"},{"key":"cver","value":"2.20190920.05.01"},{"key":"yt_li","value":"0"}]},{"service":"GFEEDBACK","params":[{"key":"e","value":"23744176,23757412,23788838,23788875,23793834,23804281,23808952,23818920,23828084,23828243,23829335,23832543,23835014,23836965,23837741,23837772,23837957,23837993,23838272,23838302,23838823,23838823,23839284,23839362,23840216,23840243,23841118,23842662,23842986,23843283,23843289,23843534,23844042,24630096,9449243,9471235"},{"key":"logged_in","value":"0"}]},{"service":"GUIDED_HELP","params":[{"key":"logged_in","value":"0"}]},{"service":"ECATCHER","params":[{"key":"client.name","value":"WEB"},{"key":"client.version","value":"2.20190920"},{"key":"innertube.build.changelist","value":"270293990"},{"key":"innertube.build.experiments.source_version","value":"270377311"},{"key":"innertube.build.label","value":"youtube.ytfe.innertube_20190920_5_RC0"},{"key":"innertube.build.timestamp","value":"1568999515"},{"key":"innertube.build.variants.checksum","value":"669625af1d321c1e95dffac8db989afa"},{"key":"innertube.run.job","value":"ytfe-innertube-replica-only.ytfe"}]}],"webResponseContextExtensionData":{"ytConfigData":{"csn":"zeiIXfXHJYOA1d8Pyuaw4A4","visitorData":"CgtLWW1kYjAxZTBaRSjN0aPsBQ%3D%3D"}}}},"xsrf_token":"QUFFLUhqbnhXaGhpblNhWmEzdjJJR2JNeW02M01PQ0p6Z3xBQ3Jtc0ttekpfU1dhZlA4ZWJhSGNrOFN5ZGFFSmNSMjBWRERWYUtOSS03RG5sbDRaa01KWmZFd2pPZzNEdW10WThmUXRiQjRKQ1ZPUkd1b09nT0k5dEZJTGdFYWxEVGNOWkUzcGNEQjdTNnN2OTRjN1Qtc0haZlpSWGlxd1k4LUdnVEhVb1FtMW8yZHJfankzN1JhUFo3aFZvS0s4NkIzTGc=","url":"\/live_chat\/get_live_chat?continuation=0ofMyAORAhqsAUNqZ0tEUW9MWjAwdGEwMWFaMmRxY2xrcUp3b1lWVU53VGtneVdtc3laM2N6U2tKcVYwRkxVM2xhWTFGUkVndG5UUzFyVFZwbloycHlXUnBEcXJuQnZRRTlDanRvZEhSd2N6b3ZMM2QzZHk1NWIzVjBkV0psTG1OdmJTOXNhWFpsWDJOb1lYUV9kajFuVFMxclRWcG5aMnB5V1NacGMxOXdiM0J2ZFhROU1TQUMo5-aA_KPn5AIwADgAQAJKKwgAEAAYACAAKg5zdGF0aWNjaGVja3N1bToAQABKAggBUMKMlt2k5-QCWANQ5KzA_KPn5AJYt7rZo9Tm5AJoAYIBAggBiAEAoAHorZb_pOfkAg%253D%253D","endpoint":{"commandMetadata":{"webCommandMetadata":{"url":"/live_chat/get_live_chat?continuation=0ofMyAORAhqsAUNqZ0tEUW9MWjAwdGEwMWFaMmRxY2xrcUp3b1lWVU53VGtneVdtc3laM2N6U2tKcVYwRkxVM2xhWTFGUkVndG5UUzFyVFZwbloycHlXUnBEcXJuQnZRRTlDanRvZEhSd2N6b3ZMM2QzZHk1NWIzVjBkV0psTG1OdmJTOXNhWFpsWDJOb1lYUV9kajFuVFMxclRWcG5aMnB5V1NacGMxOXdiM0J2ZFhROU1TQUMo5-aA_KPn5AIwADgAQAJKKwgAEAAYACAAKg5zdGF0aWNjaGVja3N1bToAQABKAggBUMKMlt2k5-QCWANQ5KzA_KPn5AJYt7rZo9Tm5AJoAYIBAggBiAEAoAHorZb_pOfkAg%253D%253D"}},"urlEndpoint":{"url":"/live_chat/get_live_chat?continuation=0ofMyAORAhqsAUNqZ0tEUW9MWjAwdGEwMWFaMmRxY2xrcUp3b1lWVU53VGtneVdtc3laM2N6U2tKcVYwRkxVM2xhWTFGUkVndG5UUzFyVFZwbloycHlXUnBEcXJuQnZRRTlDanRvZEhSd2N6b3ZMM2QzZHk1NWIzVjBkV0psTG1OdmJTOXNhWFpsWDJOb1lYUV9kajFuVFMxclRWcG5aMnB5V1NacGMxOXdiM0J2ZFhROU1TQUMo5-aA_KPn5AIwADgAQAJKKwgAEAAYACAAKg5zdGF0aWNjaGVja3N1bToAQABKAggBUMKMlt2k5-QCWANQ5KzA_KPn5AJYt7rZo9Tm5AJoAYIBAggBiAEAoAHorZb_pOfkAg%253D%253D"}},"timing":{"info":{"st":64}}} \ No newline at end of file diff --git a/tests/testdata/paramgen_firstread.json b/tests/testdata/paramgen_firstread.json new file mode 100644 index 0000000..591c0ae --- /dev/null +++ b/tests/testdata/paramgen_firstread.json @@ -0,0 +1 @@ +{"endpoint":{"commandMetadata":{"webCommandMetadata":{"url":"/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAmpeOs-PkAjAAOABAAkorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CAmpeOs-PkAliAmpeOs-PkAmgBggEECAEQAIgBAKABgJqXjrPj5AI%253D"}},"urlEndpoint":{"url":"/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAmpeOs-PkAjAAOABAAkorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CAmpeOs-PkAliAmpeOs-PkAmgBggEECAEQAIgBAKABgJqXjrPj5AI%253D"}},"xsrf_token":"QUFFLUhqbUxQV1djRnhRZFhmX1p2WklrX09xS3VFUHZsUXxBQ3Jtc0tuMkZIOUJkMlZubnVYRUlOa0RhYUhpUkpUN213NkJyajA0SGF4NmlKQVlYVkZ4QV85Wlc2MXlWcW1pZnBVcTJZRzgxcGQzTkw2ZEM1cTNUdEZuQlB5ckRfRFdGWkt1M3FGTWlIRFpjQkFuam9oOW44aGc0RXNIMWtyblRxQlhDQVBfVWxYaDVjd3NPRUloZkh3cFNqTXR4ZUxJN2c=","response":{"responseContext":{"serviceTrackingParams":[{"service":"CSI","params":[{"key":"GetLiveChat_rid","value":"0x83eb22f75e077d28"},{"key":"c","value":"WEB"},{"key":"cver","value":"2.20190920.05.01"},{"key":"yt_li","value":"0"}]},{"service":"GFEEDBACK","params":[{"key":"e","value":"23744176,23748146,23788851,23788875,23793834,23804281,23807353,23808952,23828082,23828243,23829333,23832544,23834418,23834656,23835020,23836434,23836965,23837742,23837772,23837993,23838301,23838576,23838576,23838742,23839360,23840216,23841655,23842986,23843288,23843533,23843743,23844780,24630231,9425362,9449243,9466592,9469037,9471235,9474358"},{"key":"logged_in","value":"0"}]},{"service":"GUIDED_HELP","params":[{"key":"logged_in","value":"0"}]},{"service":"ECATCHER","params":[{"key":"client.name","value":"WEB"},{"key":"client.version","value":"2.20190920"},{"key":"innertube.build.changelist","value":"270169303"},{"key":"innertube.build.experiments.source_version","value":"270377311"},{"key":"innertube.build.label","value":"youtube.ytfe.innertube_20190919_5_RC1"},{"key":"innertube.build.timestamp","value":"1568942548"},{"key":"innertube.build.variants.checksum","value":"392d499f55b5e2c240adde58886a8143"},{"key":"innertube.run.job","value":"ytfe-innertube-replica-only.ytfe"}]}],"webResponseContextExtensionData":{"ytConfigData":{"csn":"ld6GXdDdDqeDs8IP54Cz4AY","visitorData":"CgtKUldQeGJJRXhkcyiVvZvsBQ%3D%3D"}}},"continuationContents":{"liveChatContinuation":{"continuations":[{"timedContinuationData":{"timeoutMs":5035,"continuation":"0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D"}}],"actions":[{"addChatItemAction":{"item":{"liveChatTextMessageRenderer":{"message":{"runs":[{"text":"伝説を見た!"}]},"authorName":{"simpleText":"2021 2526"},"authorPhoto":{"thumbnails":[{"url":"https://yt3.ggpht.com/-yTRb9pvMpGU/AAAAAAAAAAI/AAAAAAAAAAA/wZTSSVY3evw/s32-c-k-no-mo-rj-c0xffffff/photo.jpg","width":32,"height":32},{"url":"https://yt3.ggpht.com/-yTRb9pvMpGU/AAAAAAAAAAI/AAAAAAAAAAA/wZTSSVY3evw/s64-c-k-no-mo-rj-c0xffffff/photo.jpg","width":64,"height":64}]},"contextMenuEndpoint":{"commandMetadata":{"webCommandMetadata":{"ignoreNavigation":true}},"liveChatItemContextMenuEndpoint":{"params":"Q2owS093b2FRMGxQVlMwME1ubzBMVkZEUmxsdE0yZG5iMlIyZVUxRWIyY1NIVU5KYVhreVpWTjJOQzFSUTBaWE9VTm9VVzlrYUc1clNsVkJMVEV4RUFBYU9Bb05DZ3RyTkV0M1NWZFBTME4yTkNvbkNoaFZRMjlKY1c0eFdqVnBlRU5hZDJNd1JGZEtOM0pMUTJjU0MyczBTM2RKVjA5TFEzWTBJQUlvQVRJYUNoaFZRMDVzUm1KcVlXMU5SV1ZxU21sbVNVWldUMjU2VlZFJTNE"}},"id":"CjsKGkNJT1UtNDJ6NC1RQ0ZZbTNnZ29kdnlNRG9nEh1DSWl5MmVTdjQtUUNGVzlDaFFvZGhua0pVQS0xMQ%3D%3D","timestampUsec":"1569119891540483","authorExternalChannelId":"UCNlFbjamMEejJifIFVOnzUQ","contextMenuAccessibility":{"accessibilityData":{"label":"コメントの操作"}}}},"clientId":"CIiy2eSv4-QCFW9ChQodhnkJUA-11"}},{"addChatItemAction":{"item":{"liveChatTextMessageRenderer":{"message":{"runs":[{"text":"あかん、なんもできん。見たい。"}]},"authorName":{"simpleText":"takarakujihazure"},"authorPhoto":{"thumbnails":[{"url":"https://yt3.ggpht.com/-A7GmQ36IIjI/AAAAAAAAAAI/AAAAAAAAAAA/5NehWDlHx9Q/s32-c-k-no-mo-rj-c0xffffff/photo.jpg","width":32,"height":32},{"url":"https://yt3.ggpht.com/-A7GmQ36IIjI/AAAAAAAAAAI/AAAAAAAAAAA/5NehWDlHx9Q/s64-c-k-no-mo-rj-c0xffffff/photo.jpg","width":64,"height":64}]},"contextMenuEndpoint":{"commandMetadata":{"webCommandMetadata":{"ignoreNavigation":true}},"liveChatItemContextMenuEndpoint":{"params":"Q2p3S09nb2FRMGxmVUc0ME5ubzBMVkZEUmxFMmVXZG5iMlJSWkVWS00xRVNIRU5MUzB0M2NVZG9OQzFSUTBaWk1IUlpRVzlrTWtaalRGQm5NVGNRQUJvNENnMEtDMnMwUzNkSlYwOUxRM1kwS2ljS0dGVkRiMGx4YmpGYU5XbDRRMXAzWXpCRVYwbzNja3REWnhJTGF6UkxkMGxYVDB0RGRqUWdBaWdCTWhvS0dGVkRUa1JOYTNsV2NITkZTVEk0TFU1MmRFTnNhSFJrZHclM0QlM0Q="}},"id":"CjoKGkNJX1BuNDZ6NC1RQ0ZRNnlnZ29kUWRFSjNREhxDS0tLd3FHaDQtUUNGWTB0WUFvZDJGY0xQZzE3","timestampUsec":"1569119892137871","authorExternalChannelId":"UCNDMkyVpsEI28-NvtClhtdw","contextMenuAccessibility":{"accessibilityData":{"label":"コメントの操作"}}}},"clientId":"CKKKwqGh4-QCFY0tYAod2FcLPg17"}}]}}},"csn":"ld6GXdDdDqeDs8IP54Cz4AY","url":"\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAmpeOs-PkAjAAOABAAkorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CAmpeOs-PkAliAmpeOs-PkAmgBggEECAEQAIgBAKABgJqXjrPj5AI%253D","timing":{"info":{"st":91}}} \ No newline at end of file