Merge branch 'release/v0.0.4.3'

This commit is contained in:
taizan-hokuto
2020-01-08 01:27:06 +09:00
18 changed files with 1111 additions and 221 deletions

View File

@@ -1,3 +1,7 @@
include requirements.txt include requirements.txt
include requirements_test.txt include requirements_test.txt
prune testrun*.py
prune log.txt
prune quote.txt
prune .gitignore
prun tests

View File

@@ -46,11 +46,12 @@ def display(data):
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}") print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
data.tick() data.tick()
#entry point if __name__ == '__main__':
chat = LiveChat("rsHWP7IjMiw", callback = display) chat = LiveChat("rsHWP7IjMiw", callback = display)
while chat.is_alive(): while chat.is_alive():
time.sleep(3) #other background operation.
#other background operation. time.sleep(3)
``` ```
### asyncio context: ### asyncio context:
@@ -62,8 +63,8 @@ import asyncio
async def main(): async def main():
chat = LiveChatAsync("rsHWP7IjMiw", callback = func) chat = LiveChatAsync("rsHWP7IjMiw", callback = func)
while chat.is_alive(): while chat.is_alive():
await asyncio.sleep(3)
#other background operation. #other background operation.
await asyncio.sleep(3)
#callback function is automatically called. #callback function is automatically called.
async def func(data): async def func(data):
@@ -71,11 +72,12 @@ async def func(data):
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}") print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
await data.tick_async() await data.tick_async()
try: if __name__ == '__main__':
loop = asyncio.get_event_loop() try:
loop.run_until_complete(main()) loop = asyncio.get_event_loop()
except CancelledError: loop.run_until_complete(main())
pass except CancelledError:
pass
``` ```
@@ -97,9 +99,12 @@ while chat.is_alive():
time.sleep(polling/len(data['items'])) time.sleep(polling/len(data['items']))
``` ```
### replay: ### replay:
If specified video is not live,
automatically try to fetch archived chat data.
```python ```python
from pytchat import ReplayChat from pytchat import LiveChat
def main(): def main():
#seektime (seconds): start position of chat. #seektime (seconds): start position of chat.
@@ -110,7 +115,8 @@ def main():
print(f"{c.elapsedTime} [{c.author.name}]-{c.message} {c.amountString}") print(f"{c.elapsedTime} [{c.author.name}]-{c.message} {c.amountString}")
data.tick() data.tick()
main() if __name__ == '__main__':
main()
``` ```
## Structure of Default Processor ## Structure of Default Processor

View File

@@ -2,7 +2,7 @@
pytchat is a python library for fetching youtube live chat without using yt api, Selenium, or BeautifulSoup. pytchat is a python library for fetching youtube live chat without using yt api, Selenium, or BeautifulSoup.
""" """
__copyright__ = 'Copyright (C) 2019 taizan-hokuto' __copyright__ = 'Copyright (C) 2019 taizan-hokuto'
__version__ = '0.0.4.2' __version__ = '0.0.4.3'
__license__ = 'MIT' __license__ = 'MIT'
__author__ = 'taizan-hokuto' __author__ = 'taizan-hokuto'
__author_email__ = '55448286+taizan-hokuto@users.noreply.github.com' __author_email__ = '55448286+taizan-hokuto@users.noreply.github.com'

View File

@@ -8,11 +8,12 @@ import traceback
import urllib.parse import urllib.parse
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from concurrent.futures import CancelledError from concurrent.futures import CancelledError
from asyncio import Queue
from .buffer import Buffer from .buffer import Buffer
from ..parser.live import Parser from ..parser.live import Parser
from .. import config from .. import config
from ..exceptions import ChatParseException,IllegalFunctionCall from ..exceptions import ChatParseException,IllegalFunctionCall
from ..paramgen import liveparam from ..paramgen import liveparam, arcparam
from ..processors.default.processor import DefaultProcessor from ..processors.default.processor import DefaultProcessor
from ..processors.combinator import Combinator from ..processors.combinator import Combinator
@@ -29,6 +30,11 @@ class LiveChatAsync:
video_id : str video_id : str
動画ID 動画ID
seektime : int
(ライブチャット取得時は無視)
取得開始するアーカイブ済みチャットの経過時間(秒)
マイナス値を指定した場合は、配信開始前のチャットも取得する。
processor : ChatProcessor processor : ChatProcessor
チャットデータを加工するオブジェクト チャットデータを加工するオブジェクト
@@ -52,7 +58,11 @@ class LiveChatAsync:
direct_mode : bool direct_mode : bool
Trueの場合、bufferを使わずにcallbackを呼ぶ。 Trueの場合、bufferを使わずにcallbackを呼ぶ。
Trueの場合、callbackの設定が必須 Trueの場合、callbackの設定が必須
(設定していない場合IllegalFunctionCall例外を発生させる (設定していない場合IllegalFunctionCall例外を発生させる
force_replay : bool
Trueの場合、ライブチャットが取得できる場合であっても
強制的にアーカイブ済みチャットを取得する。
Attributes Attributes
--------- ---------
@@ -63,14 +73,18 @@ class LiveChatAsync:
_setup_finished = False _setup_finished = False
def __init__(self, video_id, def __init__(self, video_id,
seektime = 0,
processor = DefaultProcessor(), processor = DefaultProcessor(),
buffer = None, buffer = None,
interruptable = True, interruptable = True,
callback = None, callback = None,
done_callback = None, done_callback = None,
exception_handler = None, exception_handler = None,
direct_mode = False): direct_mode = False,
force_replay = False
):
self.video_id = video_id self.video_id = video_id
self.seektime = seektime
if isinstance(processor, tuple): if isinstance(processor, tuple):
self.processor = Combinator(processor) self.processor = Combinator(processor)
else: else:
@@ -81,9 +95,13 @@ class LiveChatAsync:
self._exception_handler = exception_handler self._exception_handler = exception_handler
self._direct_mode = direct_mode self._direct_mode = direct_mode
self._is_alive = True self._is_alive = True
self._parser = Parser() self._is_replay = force_replay
self._parser = Parser(is_replay = self._is_replay)
self._pauser = Queue()
self._pauser.put_nowait(None)
self._setup() self._setup()
self._first_fetch = True
self._fetch_url = "live_chat/get_live_chat?continuation="
if not LiveChatAsync._setup_finished: if not LiveChatAsync._setup_finished:
LiveChatAsync._setup_finished = True LiveChatAsync._setup_finished = True
if exception_handler == None: if exception_handler == None:
@@ -101,7 +119,7 @@ class LiveChatAsync:
if self._direct_mode: if self._direct_mode:
if self._callback is None: if self._callback is None:
raise IllegalFunctionCall( raise IllegalFunctionCall(
"direct_mode=Trueの場合callbackの設定が必須です。") "When direct_mode=True, callback parameter is required.")
else: else:
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成 #direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
if self._buffer is None: if self._buffer is None:
@@ -123,48 +141,29 @@ class LiveChatAsync:
listen_task.add_done_callback(self._done_callback) listen_task.add_done_callback(self._done_callback)
async def _startlisten(self): async def _startlisten(self):
"""最初のcontinuationパラメータを取得し、 """Fetch first continuation parameter,
_listenループのタスクを作成し開始する create and start _listen loop.
""" """
initial_continuation = await self._get_initial_continuation() initial_continuation = liveparam.getparam(self.video_id,3)
if initial_continuation is None:
self.terminate()
logger.debug(f"[{self.video_id}]No initial continuation.")
return
await self._listen(initial_continuation) 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): async def _listen(self, continuation):
''' continuationに紐付いたチャットデータを取得し ''' Fetch chat data and store them into buffer,
Bufferにチャットデータを格納、 get next continuaiton parameter and loop.
次のcontinuaitonを取得してループする。
Parameter Parameter
--------- ---------
continuation : str continuation : str
次のチャットデータ取得に必要なパラメータ parameter for next chat data
''' '''
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
while(continuation and self._is_alive): while(continuation and self._is_alive):
livechat_json = (await continuation = await self._check_pause(continuation)
self._get_livechat_json(continuation, session, headers) contents = await self._get_contents(
) continuation, session, headers)
metadata, chatdata = self._parser.parse( livechat_json ) metadata, chatdata = self._parser.parse(contents)
timeout = metadata['timeoutMs']/1000 timeout = metadata['timeoutMs']/1000
chat_component = { chat_component = {
"video_id" : self.video_id, "video_id" : self.video_id,
@@ -182,31 +181,67 @@ class LiveChatAsync:
await asyncio.sleep(diff_time) await asyncio.sleep(diff_time)
continuation = metadata.get('continuation') continuation = metadata.get('continuation')
except ChatParseException as e: except ChatParseException as e:
self.terminate() #self.terminate()
logger.error(f"{str(e)}video_id:\"{self.video_id}\"") logger.debug(f"[{self.video_id}]{str(e)}")
return return
except (TypeError , json.JSONDecodeError) : except (TypeError , json.JSONDecodeError) :
self.terminate() #self.terminate()
logger.error(f"{traceback.format_exc(limit = -1)}") logger.error(f"{traceback.format_exc(limit = -1)}")
return return
logger.debug(f"[{self.video_id}]チャット取得を終了しました。") logger.debug(f"[{self.video_id}]finished fetching chat.")
async def _check_pause(self, continuation):
if self._pauser.empty():
'''pause'''
await self._pauser.get()
'''resume:
prohibit from blocking by putting None into _pauser.
'''
self._pauser.put_nowait(None)
if not self._is_replay:
continuation = liveparam.getparam(self.video_id,3)
return continuation
async def _get_contents(self, continuation, session, headers):
'''Get 'contents' dict from livechat json.
If contents is None at first fetching,
try to fetch archive chat data.
Return:
-------
'contents' dict which includes metadata & chatdata.
'''
livechat_json = (await
self._get_livechat_json(continuation, session, headers)
)
contents = self._parser.get_contents(livechat_json)
if self._first_fetch:
if contents is None or self._is_replay:
'''Try to fetch archive chat data.'''
self._parser.is_replay = True
self._fetch_url = ("live_chat_replay/"
"get_live_chat_replay?continuation=")
continuation = arcparam.getparam(self.video_id, self.seektime)
livechat_json = (await self._get_livechat_json(
continuation, session, headers))
contents = self._parser.get_contents(livechat_json)
self._first_fetch = False
return contents
async def _get_livechat_json(self, continuation, session, headers): async def _get_livechat_json(self, continuation, session, headers):
''' '''
チャットデータが格納されたjsonデータを取得する。 Get json which includes chat data.
''' '''
continuation = urllib.parse.quote(continuation) continuation = urllib.parse.quote(continuation)
livechat_json = None livechat_json = None
status_code = 0 status_code = 0
url =( url =(
f"https://www.youtube.com/live_chat/get_live_chat?" f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1")
f"continuation={continuation}&pbj=1")
for _ in range(MAX_RETRY + 1): for _ in range(MAX_RETRY + 1):
async with session.get(url ,headers = headers) as resp: async with session.get(url ,headers = headers) as resp:
try: try:
text = await resp.text() text = await resp.text()
status_code = resp.status
livechat_json = json.loads(text) livechat_json = json.loads(text)
break break
except (ClientConnectorError,json.JSONDecodeError) : except (ClientConnectorError,json.JSONDecodeError) :
@@ -215,7 +250,6 @@ class LiveChatAsync:
else: else:
logger.error(f"[{self.video_id}]" logger.error(f"[{self.video_id}]"
f"Exceeded retry count. status_code={status_code}") f"Exceeded retry count. status_code={status_code}")
self.terminate()
return None return None
return livechat_json return livechat_json
@@ -246,6 +280,21 @@ class LiveChatAsync:
raise IllegalFunctionCall( raise IllegalFunctionCall(
"既にcallbackを登録済みのため、get()は実行できません。") "既にcallbackを登録済みのため、get()は実行できません。")
def is_replay(self):
return self._is_replay
def pause(self):
if self._callback is None:
return
if not self._pauser.empty():
self._pauser.get_nowait()
def resume(self):
if self._callback is None:
return
if self._pauser.empty():
self._pauser.put_nowait(None)
def is_alive(self): def is_alive(self):
return self._is_alive return self._is_alive
@@ -263,18 +312,16 @@ class LiveChatAsync:
self._is_alive = False self._is_alive = False
if self._direct_mode == False: if self._direct_mode == False:
#bufferにダミーオブジェクトを入れてis_alive()を判定させる #bufferにダミーオブジェクトを入れてis_alive()を判定させる
self._buffer.put_nowait({'chatdata':'','timeout':1}) self._buffer.put_nowait({'chatdata':'','timeout':0})
logger.info(f'終了しました:[{self.video_id}]') logger.info(f'[{self.video_id}]finished.')
@classmethod @classmethod
def _set_exception_handler(cls, handler): def _set_exception_handler(cls, handler):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
#default handler: cls._handle_exception
loop.set_exception_handler(handler) loop.set_exception_handler(handler)
@classmethod @classmethod
def _handle_exception(cls, loop, context): def _handle_exception(cls, loop, context):
#msg = context.get("exception", context["message"])
if not isinstance(context["exception"],CancelledError): if not isinstance(context["exception"],CancelledError):
logger.error(f"Caught exception: {context}") logger.error(f"Caught exception: {context}")
loop= asyncio.get_event_loop() loop= asyncio.get_event_loop()
@@ -282,12 +329,12 @@ class LiveChatAsync:
@classmethod @classmethod
async def shutdown(cls, event, sig = None, handler=None): async def shutdown(cls, event, sig = None, handler=None):
logger.debug("シャットダウンしています") logger.debug("shutdown...")
tasks = [t for t in asyncio.all_tasks() if t is not tasks = [t for t in asyncio.all_tasks() if t is not
asyncio.current_task()] asyncio.current_task()]
[task.cancel() for task in tasks] [task.cancel() for task in tasks]
logger.debug(f"残っているタスクを終了しています") logger.debug(f"complete remaining tasks...")
await asyncio.gather(*tasks,return_exceptions=True) await asyncio.gather(*tasks,return_exceptions=True)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.stop() loop.stop()

View File

@@ -6,9 +6,10 @@ import signal
import time import time
import traceback import traceback
import urllib.parse import urllib.parse
import warnings
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from concurrent.futures import CancelledError from concurrent.futures import CancelledError
from queue import Queue from asyncio import Queue
from .buffer import Buffer from .buffer import Buffer
from ..parser.replay import Parser from ..parser.replay import Parser
from .. import config from .. import config
@@ -18,13 +19,22 @@ from ..processors.default.processor import DefaultProcessor
from ..processors.combinator import Combinator from ..processors.combinator import Combinator
logger = config.logger(__name__) logger = config.logger(__name__)
MAX_RETRY = 10
headers = config.headers headers = config.headers
MAX_RETRY = 10
class ReplayChatAsync: class ReplayChatAsync:
'''asyncio(aiohttp)を利用してYouTubeのチャットデータを取得する。 '''
### -----------------------------------------------------------
### [Warning] ReplayChatAsync is integrated into LiveChatAsync.
### This class is deprecated and will be removed at v0.0.5.0.
### ReplayChatAsyncはLiveChatAsyncに統合しました。
### このクラスはv0.0.5.0で廃止予定です。
### -----------------------------------------------------------
asyncio(aiohttp)を利用してYouTubeのチャットデータを取得する。
Parameter Parameter
--------- ---------
@@ -76,6 +86,12 @@ class ReplayChatAsync:
done_callback = None, done_callback = None,
exception_handler = None, exception_handler = None,
direct_mode = False): direct_mode = False):
warnings.warn(""
f"\n{'-'*60}\n[WARNING] ReplayChatAsync is integrated "
f"into LiveChatAsync.\n{' '*5} This is deprecated and will"
f" be removed at v0.0.5.0.\n{'-'*60}\n"
)
self.video_id = video_id self.video_id = video_id
self.seektime = seektime self.seektime = seektime
if isinstance(processor, tuple): if isinstance(processor, tuple):
@@ -135,28 +151,9 @@ class ReplayChatAsync:
"""最初のcontinuationパラメータを取得し、 """最初のcontinuationパラメータを取得し、
_listenループのタスクを作成し開始する _listenループのタスクを作成し開始する
""" """
initial_continuation = await self._get_initial_continuation() initial_continuation = arcparam.getparam(self.video_id, self.seektime)
if initial_continuation is None:
self.terminate()
logger.debug(f"[{self.video_id}]No initial continuation.")
return
await self._listen(initial_continuation) await self._listen(initial_continuation)
async def _get_initial_continuation(self):
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
try:
initial_continuation = arcparam.get(self.video_id,self.seektime)
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): async def _listen(self, continuation):
''' continuationに紐付いたチャットデータを取得し ''' continuationに紐付いたチャットデータを取得し
Bufferにチャットデータを格納、 Bufferにチャットデータを格納、
@@ -171,11 +168,13 @@ class ReplayChatAsync:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
while(continuation and self._is_alive): while(continuation and self._is_alive):
if self._pauser.empty(): if self._pauser.empty():
#pause '''pause'''
await self._pauser.get() await self._pauser.get()
#resume '''resume:
#prohibit from blocking by putting None into _pauser. prohibit from blocking by putting None into _pauser.
'''
self._pauser.put_nowait(None) self._pauser.put_nowait(None)
#when replay, not reacquire continuation param
livechat_json = (await livechat_json = (await
self._get_livechat_json(continuation, session, headers) self._get_livechat_json(continuation, session, headers)
) )
@@ -197,11 +196,12 @@ class ReplayChatAsync:
await asyncio.sleep(diff_time) await asyncio.sleep(diff_time)
continuation = metadata.get('continuation') continuation = metadata.get('continuation')
except ChatParseException as e: except ChatParseException as e:
self.terminate()
logger.error(f"{str(e)}video_id:\"{self.video_id}\"") logger.error(f"{str(e)}video_id:\"{self.video_id}\"")
return return
except (TypeError , json.JSONDecodeError) : except (TypeError , json.JSONDecodeError) :
logger.error(f"{traceback.format_exc(limit = -1)}")
self.terminate() self.terminate()
logger.error(f"{traceback.format_exc(limit = -1)}")
return return
logger.debug(f"[{self.video_id}]チャット取得を終了しました。") logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
@@ -261,14 +261,17 @@ class ReplayChatAsync:
"既にcallbackを登録済みのため、get()は実行できません。") "既にcallbackを登録済みのため、get()は実行できません。")
def pause(self): def pause(self):
if self._callback is None:
return
if not self._pauser.empty(): if not self._pauser.empty():
self._pauser.get() self._pauser.get_nowait()
def resume(self): def resume(self):
if self._callback is None:
return
if self._pauser.empty(): if self._pauser.empty():
self._pauser.put_nowait(None) self._pauser.put_nowait(None)
def is_alive(self): def is_alive(self):
return self._is_alive return self._is_alive
@@ -292,12 +295,10 @@ class ReplayChatAsync:
@classmethod @classmethod
def _set_exception_handler(cls, handler): def _set_exception_handler(cls, handler):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
#default handler: cls._handle_exception
loop.set_exception_handler(handler) loop.set_exception_handler(handler)
@classmethod @classmethod
def _handle_exception(cls, loop, context): def _handle_exception(cls, loop, context):
#msg = context.get("exception", context["message"])
if not isinstance(context["exception"],CancelledError): if not isinstance(context["exception"],CancelledError):
logger.error(f"Caught exception: {context}") logger.error(f"Caught exception: {context}")
loop= asyncio.get_event_loop() loop= asyncio.get_event_loop()

View File

@@ -7,11 +7,12 @@ import time
import traceback import traceback
import urllib.parse import urllib.parse
from concurrent.futures import CancelledError, ThreadPoolExecutor from concurrent.futures import CancelledError, ThreadPoolExecutor
from queue import Queue
from .buffer import Buffer from .buffer import Buffer
from ..parser.live import Parser from ..parser.live import Parser
from .. import config from .. import config
from ..exceptions import ChatParseException,IllegalFunctionCall from ..exceptions import ChatParseException,IllegalFunctionCall
from ..paramgen import liveparam from ..paramgen import liveparam, arcparam
from ..processors.default.processor import DefaultProcessor from ..processors.default.processor import DefaultProcessor
from ..processors.combinator import Combinator from ..processors.combinator import Combinator
@@ -27,6 +28,11 @@ class LiveChat:
--------- ---------
video_id : str video_id : str
動画ID 動画ID
seektime : int
(ライブチャット取得時は無視)
取得開始するアーカイブ済みチャットの経過時間(秒)
マイナス値を指定した場合は、配信開始前のチャットも取得する。
processor : ChatProcessor processor : ChatProcessor
チャットデータを加工するオブジェクト チャットデータを加工するオブジェクト
@@ -50,6 +56,10 @@ class LiveChat:
Trueの場合、callbackの設定が必須 Trueの場合、callbackの設定が必須
(設定していない場合IllegalFunctionCall例外を発生させる (設定していない場合IllegalFunctionCall例外を発生させる
force_replay : bool
Trueの場合、ライブチャットが取得できる場合であっても
強制的にアーカイブ済みチャットを取得する。
Attributes Attributes
--------- ---------
_executor : ThreadPoolExecutor _executor : ThreadPoolExecutor
@@ -63,14 +73,17 @@ class LiveChat:
#チャット監視中のListenerのリスト #チャット監視中のListenerのリスト
_listeners= [] _listeners= []
def __init__(self, video_id, def __init__(self, video_id,
seektime = 0,
processor = DefaultProcessor(), processor = DefaultProcessor(),
buffer = None, buffer = None,
interruptable = True, interruptable = True,
callback = None, callback = None,
done_callback = None, done_callback = None,
direct_mode = False direct_mode = False,
force_replay = False
): ):
self.video_id = video_id self.video_id = video_id
self.seektime = seektime
if isinstance(processor, tuple): if isinstance(processor, tuple):
self.processor = Combinator(processor) self.processor = Combinator(processor)
else: else:
@@ -81,8 +94,13 @@ class LiveChat:
self._executor = ThreadPoolExecutor(max_workers=2) self._executor = ThreadPoolExecutor(max_workers=2)
self._direct_mode = direct_mode self._direct_mode = direct_mode
self._is_alive = True self._is_alive = True
self._parser = Parser() self._is_replay = force_replay
self._parser = Parser(is_replay = self._is_replay)
self._pauser = Queue()
self._pauser.put_nowait(None)
self._setup() self._setup()
self._first_fetch = True
self._fetch_url = "live_chat/get_live_chat?continuation="
if not LiveChat._setup_finished: if not LiveChat._setup_finished:
LiveChat._setup_finished = True LiveChat._setup_finished = True
@@ -93,11 +111,12 @@ class LiveChat:
LiveChat._listeners.append(self) LiveChat._listeners.append(self)
def _setup(self): def _setup(self):
#logger.debug("setup")
#direct modeがTrueでcallback未設定の場合例外発生。 #direct modeがTrueでcallback未設定の場合例外発生。
if self._direct_mode: if self._direct_mode:
if self._callback is None: if self._callback is None:
raise IllegalFunctionCall( raise IllegalFunctionCall(
"direct_mode=Trueの場合callbackの設定が必須です。") "When direct_mode=True, callback parameter is required.")
else: else:
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成 #direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
if self._buffer is None: if self._buffer is None:
@@ -117,48 +136,30 @@ class LiveChat:
listen_task.add_done_callback(self._done_callback) listen_task.add_done_callback(self._done_callback)
def _startlisten(self): def _startlisten(self):
"""最初のcontinuationパラメータを取得し、 time.sleep(0.1) #sleep shortly to prohibit skipping fetching data
_listenループのタスクを作成し開始する """Fetch first continuation parameter,
create and start _listen loop.
""" """
initial_continuation = self._get_initial_continuation() initial_continuation = liveparam.getparam(self.video_id,3)
if initial_continuation is None:
self.terminate()
logger.debug(f"[{self.video_id}]No initial continuation.")
return
self._listen(initial_continuation) 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): def _listen(self, continuation):
''' continuationに紐付いたチャットデータを取得し ''' Fetch chat data and store them into buffer,
Bufferにチャットデータを格納、 get next continuaiton parameter and loop.
次のcontinuaitonを取得してループする。
Parameter Parameter
--------- ---------
continuation : str continuation : str
次のチャットデータ取得に必要なパラメータ parameter for next chat data
''' '''
try: try:
with requests.Session() as session: with requests.Session() as session:
while(continuation and self._is_alive): while(continuation and self._is_alive):
livechat_json = ( continuation = self._check_pause(continuation)
self._get_livechat_json(continuation, session, headers) contents = self._get_contents(
) continuation, session, headers)
metadata, chatdata = self._parser.parse( livechat_json ) metadata, chatdata = self._parser.parse(contents)
timeout = metadata['timeoutMs']/1000 timeout = metadata['timeoutMs']/1000
chat_component = { chat_component = {
"video_id" : self.video_id, "video_id" : self.video_id,
@@ -173,35 +174,68 @@ class LiveChat:
else: else:
self._buffer.put(chat_component) self._buffer.put(chat_component)
diff_time = timeout - (time.time()-time_mark) diff_time = timeout - (time.time()-time_mark)
if diff_time < 0 : diff_time=0 time.sleep(diff_time if diff_time > 0 else 0)
time.sleep(diff_time)
continuation = metadata.get('continuation') continuation = metadata.get('continuation')
except ChatParseException as e: except ChatParseException as e:
self.terminate() logger.debug(f"[{self.video_id}]{str(e)}")
logger.error(f"{str(e)}video_id:\"{self.video_id}\"")
return return
except (TypeError , json.JSONDecodeError) : except (TypeError , json.JSONDecodeError) :
self.terminate()
logger.error(f"{traceback.format_exc(limit = -1)}") logger.error(f"{traceback.format_exc(limit = -1)}")
return return
logger.debug(f"[{self.video_id}]チャット取得を終了しました。") logger.debug(f"[{self.video_id}]finished fetching chat.")
def _check_pause(self, continuation):
if self._pauser.empty():
'''pause'''
self._pauser.get()
'''resume:
prohibit from blocking by putting None into _pauser.
'''
self._pauser.put_nowait(None)
if not self._is_replay:
continuation = liveparam.getparam(self.video_id,3)
return continuation
def _get_contents(self, continuation, session, headers):
'''Get 'contents' dict from livechat json.
If contents is None at first fetching,
try to fetch archive chat data.
Return:
-------
'contents' dict which includes metadata & chatdata.
'''
livechat_json = (
self._get_livechat_json(continuation, session, headers)
)
contents = self._parser.get_contents(livechat_json)
if self._first_fetch:
if contents is None or self._is_replay:
'''Try to fetch archive chat data.'''
self._parser.is_replay = True
self._fetch_url = ("live_chat_replay/"
"get_live_chat_replay?continuation=")
continuation = arcparam.getparam(self.video_id, self.seektime)
livechat_json = ( self._get_livechat_json(
continuation, session, headers))
contents = self._parser.get_contents(livechat_json)
self._first_fetch = False
return contents
def _get_livechat_json(self, continuation, session, headers): def _get_livechat_json(self, continuation, session, headers):
''' '''
チャットデータが格納されたjsonデータを取得する。 Get json which includes chat data.
''' '''
continuation = urllib.parse.quote(continuation) continuation = urllib.parse.quote(continuation)
livechat_json = None livechat_json = None
status_code = 0 status_code = 0
url =( url =(
f"https://www.youtube.com/live_chat/get_live_chat?" f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1")
f"continuation={continuation}&pbj=1")
for _ in range(MAX_RETRY + 1): for _ in range(MAX_RETRY + 1):
with session.get(url ,headers = headers) as resp: with session.get(url ,headers = headers) as resp:
try: try:
text = resp.text text = resp.text
status_code = resp.status_code
livechat_json = json.loads(text) livechat_json = json.loads(text)
break break
except json.JSONDecodeError : except json.JSONDecodeError :
@@ -210,7 +244,6 @@ class LiveChat:
else: else:
logger.error(f"[{self.video_id}]" logger.error(f"[{self.video_id}]"
f"Exceeded retry count. status_code={status_code}") f"Exceeded retry count. status_code={status_code}")
self.terminate()
return None return None
return livechat_json return livechat_json
@@ -241,6 +274,21 @@ class LiveChat:
raise IllegalFunctionCall( raise IllegalFunctionCall(
"既にcallbackを登録済みのため、get()は実行できません。") "既にcallbackを登録済みのため、get()は実行できません。")
def is_replay(self):
return self._is_replay
def pause(self):
if self._callback is None:
return
if not self._pauser.empty():
self._pauser.get()
def resume(self):
if self._callback is None:
return
if self._pauser.empty():
self._pauser.put_nowait(None)
def is_alive(self): def is_alive(self):
return self._is_alive return self._is_alive
@@ -258,11 +306,11 @@ class LiveChat:
self._is_alive = False self._is_alive = False
if self._direct_mode == False: if self._direct_mode == False:
#bufferにダミーオブジェクトを入れてis_alive()を判定させる #bufferにダミーオブジェクトを入れてis_alive()を判定させる
self._buffer.put({'chatdata':'','timeout':1}) self._buffer.put({'chatdata':'','timeout':0})
logger.info(f'[{self.video_id}]終了しました') logger.info(f'[{self.video_id}]finished.')
@classmethod @classmethod
def shutdown(cls, event, sig = None, handler=None): def shutdown(cls, event, sig = None, handler=None):
logger.debug("シャットダウンしています") logger.debug("shutdown...")
for t in LiveChat._listeners: for t in LiveChat._listeners:
t._is_alive = False t._is_alive = False

View File

@@ -6,6 +6,7 @@ import signal
import time import time
import traceback import traceback
import urllib.parse import urllib.parse
import warnings
from concurrent.futures import CancelledError, ThreadPoolExecutor from concurrent.futures import CancelledError, ThreadPoolExecutor
from queue import Queue from queue import Queue
from .buffer import Buffer from .buffer import Buffer
@@ -22,7 +23,15 @@ MAX_RETRY = 10
class ReplayChat: class ReplayChat:
''' スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する '''
### -----------------------------------------------------------
### [Warning] ReplayChat is integrated into LiveChat.
### This class is deprecated and will be removed at v0.0.5.0.
### ReplayChatはLiveChatに統合しました。
### このクラスはv0.0.5.0で廃止予定です。
### -----------------------------------------------------------
スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する
Parameter Parameter
--------- ---------
@@ -64,8 +73,10 @@ class ReplayChat:
''' '''
_setup_finished = False _setup_finished = False
#チャット監視中のListenerのリスト #チャット監視中のListenerのリスト
_listeners= [] _listeners= []
def __init__(self, video_id, def __init__(self, video_id,
seektime = 0, seektime = 0,
processor = DefaultProcessor(), processor = DefaultProcessor(),
@@ -75,6 +86,12 @@ class ReplayChat:
done_callback = None, done_callback = None,
direct_mode = False direct_mode = False
): ):
warnings.warn(""
f"\n{'-'*60}\n[WARNING] ReplayChat is integrated into LiveChat.\n"
f"{' '*5}This is deprecated and will be removed at v0.0.5.0.\n"
f"{'-'*60}\n"
)
self.video_id = video_id self.video_id = video_id
self.seektime = seektime self.seektime = seektime
if isinstance(processor, tuple): if isinstance(processor, tuple):
@@ -139,7 +156,7 @@ class ReplayChat:
def _get_initial_continuation(self): def _get_initial_continuation(self):
''' チャットデータ取得に必要な最初のcontinuationを取得する。''' ''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
try: try:
initial_continuation = arcparam.get(self.video_id,self.seektime) initial_continuation = arcparam.getparam(self.video_id,self.seektime)
except ChatParseException as e: except ChatParseException as e:
self.terminate() self.terminate()
logger.debug(f"[{self.video_id}]Error:{str(e)}") logger.debug(f"[{self.video_id}]Error:{str(e)}")

View File

@@ -52,32 +52,18 @@ def _nval(val):
buf += val.to_bytes(1,'big') buf += val.to_bytes(1,'big')
return buf return buf
def _build(video_id, seektime, topchatonly = False):
def _tzparity(video_id,times):
t=0
for i,s in enumerate(video_id):
ss = ord(s)
if(ss % 2 == 0):
t += ss*(12-i)
else:
t ^= ss*i
return ((times^t) % 2).to_bytes(1,'big')
def get(video_id, seektime = 0, topchatonly = False):
switch_01 = b'\x04' if topchatonly else b'\x01' switch_01 = b'\x04' if topchatonly else b'\x01'
if seektime < 0: if seektime < 0:
raise ValueError('seektime is 0 or positive number.') times =_nval(0)
if seektime == 0:
times =_nval(1)
switch = b'\x04' switch = b'\x04'
elif seektime == 0:
times =_nval(1)
switch = b'\x03'
else: else:
times =_nval(int(seektime*1000000)) times =_nval(int(seektime*1000000))
switch = b'\x03' switch = b'\x03'
parity = _tzparity(video_id, seektime) parity = b'\x00'
header_magic= b'\xA2\x9D\xB0\xD3\x04' header_magic= b'\xA2\x9D\xB0\xD3\x04'
sep_0 = b'\x1A' sep_0 = b'\x1A'
@@ -88,8 +74,8 @@ def get(video_id, seektime = 0, topchatonly = False):
sep_2 = b'\x52\x1C\x08\x00\x10\x00\x18\x00\x20\x00' sep_2 = b'\x52\x1C\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\x40' chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D\x40'
sep_3 = b'\x00\x58\x03\x60' sep_3 = b'\x00\x58\x03\x60'
sep_4 = b'\x68'+parity+b'\x72\x04\x08' sep_4 = b'\x68' + parity + b'\x72\x04\x08'
sep_5 = b'\x10'+parity+b'\x78\x00' sep_5 = b'\x10' + parity + b'\x78\x00'
body = [ body = [
sep_0, sep_0,
_nval(len(vid)), _nval(len(vid)),
@@ -116,5 +102,12 @@ def get(video_id, seektime = 0, topchatonly = False):
).decode() ).decode()
) )
def getparam(video_id, seektime = 0):
'''
Parameter
---------
seektime : int
unit:seconds
start position of fetching chat data.
'''
return _build(video_id, seektime)

View File

@@ -155,7 +155,7 @@ def _times(past_sec):
return list(map(lambda x:int(x*1000000),[_ts1,_ts2,_ts3,_ts4,_ts5])) return list(map(lambda x:int(x*1000000),[_ts1,_ts2,_ts3,_ts4,_ts5]))
def getparam(video_id,past_sec = 60): def getparam(video_id,past_sec = 0):
''' '''
Parameter Parameter
--------- ---------

View File

@@ -1,7 +1,7 @@
""" """
pytchat.parser.live pytchat.parser.live
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
This module is parser of live chat JSON. Parser of live chat JSON.
""" """
import json import json
@@ -9,57 +9,83 @@ from .. import config
from .. exceptions import ( from .. exceptions import (
ResponseContextError, ResponseContextError,
NoContentsException, NoContentsException,
NoContinuationsException ) NoContinuationsException,
ChatParseException )
logger = config.logger(__name__) logger = config.logger(__name__)
class Parser: class Parser:
def parse(self, jsn):
"""
このparse関数はLiveChat._listen() 関数から定期的に呼び出される。
引数jsnはYoutubeから取得したチャットデータの生JSONであり、
このparse関数によって与えられたJSONを以下に分割して返す。
+ timeout (次のチャットデータ取得までのインターバル)
+ chat dataチャットデータ本体
+ continuation (次のチャットデータ取得に必要となるパラメータ).
__slots__ = ['is_replay']
def __init__(self, is_replay):
self.is_replay = is_replay
def get_contents(self, jsn):
if jsn is None:
raise ChatParseException('Called with none JSON object.')
if jsn['response']['responseContext'].get('errors'):
raise ResponseContextError('The video_id would be wrong, or video is deleted or private.')
contents=jsn['response'].get('continuationContents')
return contents
def parse(self, contents):
"""
Parameter Parameter
---------- ----------
+ jsn : dict + contents : dict
+ Youtubeから取得したチャットデータのJSONオブジェクト。 + JSON of chat data from YouTube.
pythonの辞書形式に変換済みの状態で渡される
Returns Returns
------- -------
tuple:
+ metadata : dict + metadata : dict
+ チャットデータに付随するメタデータ。timeout、 動画ID、continuationパラメータで構成される。 + timeout
+ chatdata : list[dict] + video_id
+ チャットデータ本体のリスト。 + continuation
+ chatdata : List[dict]
""" """
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: if contents is None:
raise NoContentsException('チャットデータを取得できませんでした。') '''Broadcasting end or cannot fetch chat stream'''
raise NoContentsException('Chat data stream is empty.')
cont = contents['liveChatContinuation']['continuations'][0] cont = contents['liveChatContinuation']['continuations'][0]
if cont is None: if cont is None:
raise NoContinuationsException('Continuationがありません。') raise NoContinuationsException('No Continuation')
metadata = (cont.get('invalidationContinuationData') or metadata = (cont.get('invalidationContinuationData') or
cont.get('timedContinuationData') or cont.get('timedContinuationData') or
cont.get('reloadContinuationData') cont.get('reloadContinuationData') or
cont.get('liveChatReplayContinuationData')
) )
if metadata is None: if metadata is None:
if cont.get("playerSeekContinuationData"):
raise ChatParseException('Finished chat data')
unknown = list(cont.keys())[0] unknown = list(cont.keys())[0]
if unknown: if unknown:
logger.debug(f"Received unknown continuation type:{unknown}") logger.debug(f"Received unknown continuation type:{unknown}")
metadata = cont.get(unknown) metadata = cont.get(unknown)
metadata.setdefault('timeoutMs', 10000) else:
chatdata = contents['liveChatContinuation'].get('actions') raise ChatParseException('Cannot extract continuation data')
return self._create_data(metadata, contents)
def _create_data(self, metadata, contents):
actions = contents['liveChatContinuation'].get('actions')
if self.is_replay:
interval = self._get_interval(actions)
metadata.setdefault("timeoutMs",interval)
"""Archived chat has different structures than live chat,
so make it the same format."""
chatdata = [action["replayChatItemAction"]["actions"][0] for action in actions]
else:
metadata.setdefault('timeoutMs', 10000)
chatdata = actions
return metadata, chatdata return metadata, chatdata
def _get_interval(self, actions: list):
if actions is None:
return 0
start = int(actions[0]["replayChatItemAction"]["videoOffsetTimeMsec"])
last = int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
return (last - start)

View File

@@ -1,5 +1,5 @@
""" """
speedmeter.py speed_calculator.py
チャットの勢いを算出するChatProcessor チャットの勢いを算出するChatProcessor
Calculate speed of chat. Calculate speed of chat.
""" """

View File

@@ -5,16 +5,16 @@ import requests, json
from pytchat.paramgen import arcparam from pytchat.paramgen import arcparam
def test_arcparam_0(mocker): def test_arcparam_0(mocker):
param = arcparam.get("01234567890") param = arcparam.getparam("01234567890",-1)
assert "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoATAAOABAAEgEUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAXIECAEQAXgA" == param assert "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoADAAOABAAEgEUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAHIECAEQAHgA" == param
def test_arcparam_1(mocker): def test_arcparam_1(mocker):
param = arcparam.get("01234567890", seektime = 100000) param = arcparam.getparam("01234567890", seektime = 100000)
assert "op2w0wR3GjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QogNDbw_QCMAA4AEAASANSHAgAEAAYACAAKg5zdGF0aWNjaGVja3N1bUAAWANgAWgBcgQIARABeAA%3D" == param assert "op2w0wR3GjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QogNDbw_QCMAA4AEAASANSHAgAEAAYACAAKg5zdGF0aWNjaGVja3N1bUAAWANgAWgAcgQIARAAeAA%3D" == param
def test_arcparam_2(mocker): def test_arcparam_2(mocker):
param = arcparam.get("SsjCnHOk-Sk") param = arcparam.getparam("SsjCnHOk-Sk")
url=f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?continuation={param}&pbj=1" url=f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?continuation={param}&pbj=1"
resp = requests.Session().get(url,headers = config.headers) resp = requests.Session().get(url,headers = config.headers)
jsn = json.loads(resp.text) jsn = json.loads(resp.text)
@@ -23,4 +23,7 @@ def test_arcparam_2(mocker):
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"] test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"]
print(test_id) print(test_id)
assert "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w" == test_id assert "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w" == test_id
def test_arcparam_3(mocker):
param = arcparam.getparam("01234567890")
assert "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoATAAOABAAEgDUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAHIECAEQAHgA" == param

View File

@@ -12,7 +12,7 @@ from pytchat.processors.compatible.renderer.paidmessage import LiveChatPaidMessa
from pytchat.processors.compatible.renderer.paidsticker import LiveChatPaidStickerRenderer from pytchat.processors.compatible.renderer.paidsticker import LiveChatPaidStickerRenderer
from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaidMessageRenderer from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
parser = Parser() parser = Parser(is_replay=False)
def test_textmessage(mocker): def test_textmessage(mocker):
'''api互換processorのテスト通常テキストメッセージ''' '''api互換processorのテスト通常テキストメッセージ'''
@@ -20,7 +20,7 @@ def test_textmessage(mocker):
_json = _open_file("tests/testdata/compatible/textmessage.json") _json = _open_file("tests/testdata/compatible/textmessage.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 7, "timeout" : 7,
@@ -57,7 +57,7 @@ def test_newsponcer(mocker):
_json = _open_file("tests/testdata/compatible/newSponsor.json") _json = _open_file("tests/testdata/compatible/newSponsor.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 7, "timeout" : 7,
@@ -93,7 +93,7 @@ def test_superchat(mocker):
_json = _open_file("tests/testdata/compatible/superchat.json") _json = _open_file("tests/testdata/compatible/superchat.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 7, "timeout" : 7,

125
tests/test_livechat_2.py Normal file
View File

@@ -0,0 +1,125 @@
import asyncio, aiohttp
import json
import pytest
import re
import requests
import sys
import time
from aioresponses import aioresponses
from concurrent.futures import CancelledError
from unittest import TestCase
from pytchat.core_multithread.livechat import LiveChat
from pytchat.core_async.livechat import LiveChatAsync
from pytchat.exceptions import (
NoLivechatRendererException,NoYtinitialdataException,
ResponseContextError,NoContentsException)
from pytchat.parser.live import Parser
from pytchat.processors.dummy_processor import DummyProcessor
def _open_file(path):
with open(path,mode ='r',encoding = 'utf-8') as f:
return f.read()
@aioresponses()
def test_async_live_stream(*mock):
async def test_loop(*mock):
pattern = re.compile(r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
_text = _open_file('tests/testdata/test_stream.json')
mock[0].get(pattern, status=200, body=_text)
chat = LiveChatAsync(video_id='', processor = DummyProcessor())
chats = await chat.get()
rawdata = chats[0]["chatdata"]
#assert fetching livachat data
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
assert list(rawdata[1]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[0] == "liveChatPlaceholderItemRenderer"
assert list(rawdata[3]["addLiveChatTickerItemAction"]["item"].keys())[0] == "liveChatTickerPaidMessageItemRenderer"
assert list(rawdata[4]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidMessageRenderer"
assert list(rawdata[5]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidStickerRenderer"
assert list(rawdata[6]["addLiveChatTickerItemAction"]["item"].keys())[0] == "liveChatTickerSponsorItemRenderer"
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(test_loop(*mock))
except CancelledError:
assert True
@aioresponses()
def test_async_replay_stream(*mock):
async def test_loop(*mock):
pattern_live = re.compile(r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
pattern_replay = re.compile(r'^https://www.youtube.com/live_chat_replay/get_live_chat_replay\?continuation=.*$')
#empty livechat -> switch to fetch replaychat
_text_live = _open_file('tests/testdata/finished_live.json')
_text_replay = _open_file('tests/testdata/chatreplay.json')
mock[0].get(pattern_live, status=200, body=_text_live)
mock[0].get(pattern_replay, status=200, body=_text_replay)
chat = LiveChatAsync(video_id='', processor = DummyProcessor())
chats = await chat.get()
rawdata = chats[0]["chatdata"]
#assert fetching replaychat data
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
assert list(rawdata[14]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidMessageRenderer"
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(test_loop(*mock))
except CancelledError:
assert True
@aioresponses()
def test_async_force_replay(*mock):
async def test_loop(*mock):
pattern_live = re.compile(r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
pattern_replay = re.compile(r'^https://www.youtube.com/live_chat_replay/get_live_chat_replay\?continuation=.*$')
#valid live data, but force_replay = True
_text_live = _open_file('tests/testdata/test_stream.json')
#valid replay data
_text_replay = _open_file('tests/testdata/chatreplay.json')
mock[0].get(pattern_live, status=200, body=_text_live)
mock[0].get(pattern_replay, status=200, body=_text_replay)
#force replay
chat = LiveChatAsync(video_id='', processor = DummyProcessor(), force_replay = True)
chats = await chat.get()
rawdata = chats[0]["chatdata"]
# assert fetching replaychat data
assert list(rawdata[14]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidMessageRenderer"
# assert not mix livechat data
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[0] != "liveChatPlaceholderItemRenderer"
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(test_loop(*mock))
except CancelledError:
assert True
def test_multithread_live_stream(mocker):
_text = _open_file('tests/testdata/test_stream.json')
responseMock = mocker.Mock()
responseMock.status_code = 200
responseMock.text = _text
mocker.patch('requests.Session.get').return_value.__enter__.return_value = responseMock
chat = LiveChat(video_id='test_id', processor = DummyProcessor())
chats = chat.get()
rawdata = chats[0]["chatdata"]
#assert fetching livachat data
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
assert list(rawdata[1]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[0] == "liveChatPlaceholderItemRenderer"
assert list(rawdata[3]["addLiveChatTickerItemAction"]["item"].keys())[0] == "liveChatTickerPaidMessageItemRenderer"
assert list(rawdata[4]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidMessageRenderer"
assert list(rawdata[5]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidStickerRenderer"
assert list(rawdata[6]["addLiveChatTickerItemAction"]["item"].keys())[0] == "liveChatTickerSponsorItemRenderer"
chat.terminate()

View File

@@ -11,7 +11,7 @@ from pytchat.exceptions import (
def _open_file(path): def _open_file(path):
with open(path,mode ='r',encoding = 'utf-8') as f: with open(path,mode ='r',encoding = 'utf-8') as f:
return f.read() return f.read()
parser = Parser() parser = Parser(is_replay = False)
@aioresponses() @aioresponses()
def test_finishedlive(*mock): def test_finishedlive(*mock):
@@ -21,7 +21,7 @@ def test_finishedlive(*mock):
_text = json.loads(_text) _text = json.loads(_text)
try: try:
parser.parse(_text) parser.parse(parser.get_contents(_text))
assert False assert False
except NoContentsException: except NoContentsException:
assert True assert True
@@ -34,7 +34,7 @@ def test_parsejson(*mock):
_text = json.loads(_text) _text = json.loads(_text)
try: try:
parser.parse(_text) parser.parse(parser.get_contents(_text))
jsn = _text jsn = _text
timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"] timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"]
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"] continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"]

View File

@@ -9,7 +9,7 @@ from pytchat.exceptions import (
from pytchat.processors.speed_calculator import SpeedCalculator from pytchat.processors.speed_calculator import SpeedCalculator
parser = Parser() parser = Parser(is_replay =False)
def test_speed_1(mocker): def test_speed_1(mocker):
'''test speed calculation with normal json. '''test speed calculation with normal json.
@@ -21,7 +21,7 @@ def test_speed_1(mocker):
_json = _open_file("tests/testdata/speed/speedtest_normal.json") _json = _open_file("tests/testdata/speed/speedtest_normal.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 10, "timeout" : 10,
@@ -37,7 +37,7 @@ def test_speed_2(mocker):
_json = _open_file("tests/testdata/speed/speedtest_undefined.json") _json = _open_file("tests/testdata/speed/speedtest_undefined.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 10, "timeout" : 10,
@@ -53,7 +53,7 @@ def test_speed_3(mocker):
_json = _open_file("tests/testdata/speed/speedtest_empty.json") _json = _open_file("tests/testdata/speed/speedtest_empty.json")
_, chatdata = parser.parse(json.loads(_json)) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id" : "",
"timeout" : 10, "timeout" : 10,

View File

@@ -1 +1,112 @@
{"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}}} {
"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
}
}
}

509
tests/testdata/test_stream.json vendored Normal file
View File

@@ -0,0 +1,509 @@
{
"response": {
"responseContext": {
"webResponseContextExtensionData": ""
},
"continuationContents": {
"liveChatContinuation": {
"continuations": [
{
"invalidationContinuationData": {
"invalidationId": {
"objectSource": 1000,
"objectId": "___objectId___",
"topic": "chat~00000000000~0000000",
"subscribeToGcmTopics": true,
"protoCreationTimestampMs": "1577804400000"
},
"timeoutMs": 5000,
"continuation": "___continuation___"
}
}
],
"actions": [
{
"addChatItemAction": {
"item": {
"liveChatTextMessageRenderer": {
"message": {
"runs": [
{
"text": "This is normal message."
}
]
},
"authorName": {
"simpleText": "author_name"
},
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"contextMenuEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"id": "dummy_id",
"timestampUsec": 0,
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
}
}
},
"clientId": "dummy_client_id"
}
},
{
"addChatItemAction": {
"item": {
"liveChatTextMessageRenderer": {
"message": {
"runs": [
{
"text": "This is members's message"
}
]
},
"authorName": {
"simpleText": "author_name"
},
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"contextMenuEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"id": "dummy_id",
"timestampUsec": 0,
"authorBadges": [
{
"liveChatAuthorBadgeRenderer": {
"customThumbnail": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/X=s32-c-k"
},
{
"url": "https://yt3.ggpht.com/X=s32-c-k"
}
]
},
"tooltip": "メンバー2 か月)",
"accessibility": {
"accessibilityData": {
"label": "メンバー2 か月)"
}
}
}
}
],
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
}
}
},
"clientId": "dummy_client_id"
}
},
{
"addChatItemAction": {
"item": {
"liveChatPlaceholderItemRenderer": {
"id": "dummy_id",
"timestampUsec": 0
}
},
"clientId": "dummy_client_id"
}
},
{
"addLiveChatTickerItemAction": {
"item": {
"liveChatTickerPaidMessageItemRenderer": {
"id": "dummy_id",
"amount": {
"simpleText": "¥10,000"
},
"amountTextColor": 4294967295,
"startBackgroundColor": 4293271831,
"endBackgroundColor": 4291821568,
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"durationSec": 3600,
"showItemEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"showLiveChatItemEndpoint": {
"renderer": {
"liveChatPaidMessageRenderer": {
"id": "dummy_id",
"timestampUsec": 0,
"authorName": {
"simpleText": "author_name"
},
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"purchaseAmountText": {
"simpleText": "¥10,000"
},
"message": {
"runs": [
{
"text": "This is superchat message."
}
]
},
"headerBackgroundColor": 4291821568,
"headerTextColor": 4294967295,
"bodyBackgroundColor": 4293271831,
"bodyTextColor": 4294967295,
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"authorNameTextColor": 3019898879,
"contextMenuEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"timestampColor": 2164260863,
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
}
}
}
}
},
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"fullDurationSec": 3600
}
},
"durationSec": "3600"
}
},
{
"addChatItemAction": {
"item": {
"liveChatPaidMessageRenderer": {
"id": "dummy_id",
"timestampUsec": 0,
"authorName": {
"simpleText": "author_name"
},
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"purchaseAmountText": {
"simpleText": "¥10,800"
},
"message": {
"runs": [
{
"text": "This is superchat message."
}
]
},
"headerBackgroundColor": 4291821568,
"headerTextColor": 4294967295,
"bodyBackgroundColor": 4293271831,
"bodyTextColor": 4294967295,
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"authorNameTextColor": 3019898879,
"contextMenuEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"timestampColor": 2164260863,
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
}
}
}
}
},
{
"addChatItemAction": {
"item": {
"liveChatPaidStickerRenderer": {
"id": "dummy_id",
"contextMenuEndpoint": {
"clickTrackingParams": "___clickTrackingParams___",
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
},
"timestampUsec": 0,
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"authorName": {
"simpleText": "author_name"
},
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"sticker": {
"thumbnails": [
{
"url": "//lh3.googleusercontent.com/param_s=s40-rp",
"width": 40,
"height": 40
},
{
"url": "//lh3.googleusercontent.com/param_s=s80-rp",
"width": 80,
"height": 80
}
],
"accessibility": {
"accessibilityData": {
"label": "___sticker_label___"
}
}
},
"moneyChipBackgroundColor": 4280191205,
"moneyChipTextColor": 4294967295,
"purchaseAmountText": {
"simpleText": "¥150"
},
"stickerDisplayWidth": 40,
"stickerDisplayHeight": 40,
"backgroundColor": 4279592384,
"authorNameTextColor": 3019898879,
"trackingParams": "___trackingParams___"
}
}
}
},
{
"addLiveChatTickerItemAction": {
"item": {
"liveChatTickerSponsorItemRenderer": {
"id": "dummy_id",
"detailText": {
"runs": [
{
"text": "メンバー"
}
]
},
"detailTextColor": 4294967295,
"startBackgroundColor": 4279213400,
"endBackgroundColor": 4278943811,
"sponsorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"durationSec": 300,
"showItemEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"showLiveChatItemEndpoint": {
"renderer": {
"liveChatMembershipItemRenderer": {
"id": "dummy_id",
"timestampUsec": 0,
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"headerSubtext": {
"runs": [
{
"text": "メンバーシップ"
},
{
"text": " へようこそ!"
}
]
},
"authorName": {
"simpleText": "author_name"
},
"authorPhoto": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 32,
"height": 32
},
{
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
"width": 64,
"height": 64
}
]
},
"authorBadges": [
{
"liveChatAuthorBadgeRenderer": {
"customThumbnail": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/X=s32-c-k"
},
{
"url": "https://yt3.ggpht.com/X=s32-c-k"
}
]
},
"tooltip": "新規メンバー",
"accessibility": {
"accessibilityData": {
"label": "新規メンバー"
}
}
}
}
],
"contextMenuEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"ignoreNavigation": true
}
},
"liveChatItemContextMenuEndpoint": {
"params": "___params___"
}
},
"contextMenuAccessibility": {
"accessibilityData": {
"label": "コメントの操作"
}
}
}
}
}
},
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
"fullDurationSec": 300
}
},
"durationSec": "300"
}
}
]
}
}
}
}