Created base files
This commit is contained in:
1
MANIFEST.in
Normal file
1
MANIFEST.in
Normal file
@@ -0,0 +1 @@
|
||||
include requirements.txt
|
||||
120
README.md
Normal file
120
README.md
Normal file
@@ -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
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
[taizan-hokuto](https://github.com/taizan-hokuto)
|
||||
19
pytchat/__init__.py
Normal file
19
pytchat/__init__.py
Normal file
@@ -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
|
||||
)
|
||||
7
pytchat/api.py
Normal file
7
pytchat/api.py
Normal file
@@ -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
|
||||
|
||||
4
pytchat/config/__init__.py
Normal file
4
pytchat/config/__init__.py
Normal file
@@ -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'}
|
||||
0
pytchat/core_async/__init__.py
Normal file
0
pytchat/core_async/__init__.py
Normal file
28
pytchat/core_async/buffer.py
Normal file
28
pytchat/core_async/buffer.py
Normal file
@@ -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
|
||||
184
pytchat/core_async/listen_manager.py
Normal file
184
pytchat/core_async/listen_manager.py
Normal file
@@ -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
|
||||
299
pytchat/core_async/livechat.py
Normal file
299
pytchat/core_async/livechat.py
Normal file
@@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
40
pytchat/core_async/parser.py
Normal file
40
pytchat/core_async/parser.py
Normal file
@@ -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
|
||||
0
pytchat/core_multithread/__init__.py
Normal file
0
pytchat/core_multithread/__init__.py
Normal file
31
pytchat/core_multithread/buffer.py
Normal file
31
pytchat/core_multithread/buffer.py
Normal file
@@ -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
|
||||
277
pytchat/core_multithread/livechat.py
Normal file
277
pytchat/core_multithread/livechat.py
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
39
pytchat/core_multithread/parser.py
Normal file
39
pytchat/core_multithread/parser.py
Normal file
@@ -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
|
||||
43
pytchat/exceptions.py
Normal file
43
pytchat/exceptions.py
Normal file
@@ -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
|
||||
31
pytchat/mylogger.py
Normal file
31
pytchat/mylogger.py
Normal file
@@ -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
|
||||
0
pytchat/paramgen/__init__.py
Normal file
0
pytchat/paramgen/__init__.py
Normal file
143
pytchat/paramgen/liveparam.py
Normal file
143
pytchat/paramgen/liveparam.py
Normal file
@@ -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())
|
||||
|
||||
0
pytchat/processors/__init__.py
Normal file
0
pytchat/processors/__init__.py
Normal file
27
pytchat/processors/chat_processor.py
Normal file
27
pytchat/processors/chat_processor.py
Normal file
@@ -0,0 +1,27 @@
|
||||
class ChatProcessor:
|
||||
'''
|
||||
Listenerからチャットデータ(actions)を受け取り
|
||||
チャットデータを加工するクラスの抽象クラス
|
||||
'''
|
||||
def process(self, chat_components: list):
|
||||
'''
|
||||
チャットデータの加工を表すインターフェース
|
||||
Listenerから呼び出される。
|
||||
Parameter
|
||||
----------
|
||||
chat_components: list<component>
|
||||
component : dict {
|
||||
"video_id" : str
|
||||
動画ID
|
||||
"timeout" : int
|
||||
次のチャットの再読み込みまでの時間(秒)
|
||||
"chatdata" : list<object>
|
||||
チャットデータ(actions)のリスト
|
||||
}
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
0
pytchat/processors/compatible/__init__.py
Normal file
0
pytchat/processors/compatible/__init__.py
Normal file
43
pytchat/processors/compatible/parser.py
Normal file
43
pytchat/processors/compatible/parser.py
Normal file
@@ -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
|
||||
|
||||
39
pytchat/processors/compatible/processor.py
Normal file
39
pytchat/processors/compatible/processor.py
Normal file
@@ -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
|
||||
0
pytchat/processors/compatible/renderer/__init__.py
Normal file
0
pytchat/processors/compatible/renderer/__init__.py
Normal file
83
pytchat/processors/compatible/renderer/base.py
Normal file
83
pytchat/processors/compatible/renderer/base.py
Normal file
@@ -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')
|
||||
|
||||
37
pytchat/processors/compatible/renderer/currency.py
Normal file
37
pytchat/processors/compatible/renderer/currency.py
Normal file
@@ -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": "ボスニア・兌換マルカ"}
|
||||
}
|
||||
43
pytchat/processors/compatible/renderer/legacypaid.py
Normal file
43
pytchat/processors/compatible/renderer/legacypaid.py
Normal file
@@ -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
|
||||
|
||||
|
||||
41
pytchat/processors/compatible/renderer/paidmessage.py
Normal file
41
pytchat/processors/compatible/renderer/paidmessage.py
Normal file
@@ -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
|
||||
48
pytchat/processors/compatible/renderer/paidsticker.py
Normal file
48
pytchat/processors/compatible/renderer/paidsticker.py
Normal file
@@ -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
|
||||
|
||||
|
||||
4
pytchat/processors/compatible/renderer/textmessage.py
Normal file
4
pytchat/processors/compatible/renderer/textmessage.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import BaseRenderer
|
||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "textMessageEvent")
|
||||
0
pytchat/processors/default/__init__.py
Normal file
0
pytchat/processors/default/__init__.py
Normal file
39
pytchat/processors/default/parser.py
Normal file
39
pytchat/processors/default/parser.py
Normal file
@@ -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
|
||||
|
||||
44
pytchat/processors/default/processor.py
Normal file
44
pytchat/processors/default/processor.py
Normal file
@@ -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))
|
||||
|
||||
0
pytchat/processors/default/renderer/__init__.py
Normal file
0
pytchat/processors/default/renderer/__init__.py
Normal file
80
pytchat/processors/default/renderer/base.py
Normal file
80
pytchat/processors/default/renderer/base.py
Normal file
@@ -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')
|
||||
37
pytchat/processors/default/renderer/currency.py
Normal file
37
pytchat/processors/default/renderer/currency.py
Normal file
@@ -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": "ボスニア・兌換マルカ"}
|
||||
}
|
||||
18
pytchat/processors/default/renderer/legacypaid.py
Normal file
18
pytchat/processors/default/renderer/legacypaid.py
Normal file
@@ -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
|
||||
|
||||
|
||||
36
pytchat/processors/default/renderer/paidmessage.py
Normal file
36
pytchat/processors/default/renderer/paidmessage.py
Normal file
@@ -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
|
||||
13
pytchat/processors/default/renderer/paidsticker.py
Normal file
13
pytchat/processors/default/renderer/paidsticker.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import re
|
||||
from . import currency
|
||||
from .paidmessage import LiveChatPaidMessageRenderer
|
||||
|
||||
class LiveChatPaidStickerRenderer(LiveChatPaidMessageRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superSticker")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
pytchat/processors/default/renderer/textmessage.py
Normal file
4
pytchat/processors/default/renderer/textmessage.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import BaseRenderer
|
||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "textMessage")
|
||||
13
pytchat/processors/json_display_processor.py
Normal file
13
pytchat/processors/json_display_processor.py
Normal file
@@ -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])
|
||||
|
||||
46
pytchat/processors/jsonfile_archive_processor.py
Normal file
46
pytchat/processors/jsonfile_archive_processor.py
Normal file
@@ -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
|
||||
|
||||
61
pytchat/processors/simple_display_processor.py
Normal file
61
pytchat/processors/simple_display_processor.py
Normal file
@@ -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 ''
|
||||
|
||||
|
||||
15
pytchat/util/__init__.py
Normal file
15
pytchat/util/__init__.py
Normal file
@@ -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)
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -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
|
||||
56
setup.py
Normal file
56
setup.py
Normal file
@@ -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')
|
||||
)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
128
tests/test_compatible_processor.py
Normal file
128
tests/test_compatible_processor.py
Normal file
@@ -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()
|
||||
53
tests/test_livechat.py
Normal file
53
tests/test_livechat.py
Normal file
@@ -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()
|
||||
|
||||
|
||||
|
||||
44
tests/test_parser.py
Normal file
44
tests/test_parser.py
Normal file
@@ -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
|
||||
35
tests/testdata/compatible/API_NewSponsor.json
vendored
Normal file
35
tests/testdata/compatible/API_NewSponsor.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
42
tests/testdata/compatible/API_SuperChat.json
vendored
Normal file
42
tests/testdata/compatible/API_SuperChat.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
tests/testdata/compatible/API_TextMessage.json
vendored
Normal file
38
tests/testdata/compatible/API_TextMessage.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
1857
tests/testdata/compatible/newSponsor.json
vendored
Normal file
1857
tests/testdata/compatible/newSponsor.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
282
tests/testdata/compatible/superchat.json
vendored
Normal file
282
tests/testdata/compatible/superchat.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
tests/testdata/compatible/supersticker.json
vendored
Normal file
197
tests/testdata/compatible/supersticker.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
tests/testdata/compatible/textmessage.json
vendored
Normal file
177
tests/testdata/compatible/textmessage.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/testdata/finished_live.json
vendored
Normal file
1
tests/testdata/finished_live.json
vendored
Normal file
@@ -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}}}
|
||||
1
tests/testdata/paramgen_firstread.json
vendored
Normal file
1
tests/testdata/paramgen_firstread.json
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user