Compare commits

..

24 Commits

Author SHA1 Message Date
taizan-hokuto
1c817b6476 Merge branch 'release/v0.0.7.2' 2020-05-22 02:39:53 +09:00
taizan-hokuto
18b88200a8 Increment version 2020-05-22 02:29:41 +09:00
taizan-hokuto
c95d70a232 Merge branch 'hotfix/#7_cli_index_outof_range' 2020-05-22 02:28:28 +09:00
taizan-hokuto
7640586591 Merge branch 'master' into develop 2020-05-22 02:28:28 +09:00
taizan-hokuto
f7ec14e166 Fix for #7 2020-05-22 02:27:52 +09:00
taizan-hokuto
a4dacdb7d7 Merge tag 'v0.0.7.1' into develop
v0.0.7.1
2020-05-06 01:24:55 +09:00
taizan-hokuto
785a82b618 Merge branch 'release/v0.0.7.1' 2020-05-06 01:24:54 +09:00
taizan-hokuto
faf886eebd Increment version 2020-05-06 01:24:30 +09:00
taizan-hokuto
8a627414cb Merge tag 'sponsor_text' into develop 2020-05-06 01:23:37 +09:00
taizan-hokuto
d14262cbcb Merge branch 'hotfix/sponsor_text' 2020-05-06 01:23:37 +09:00
taizan-hokuto
da7c694dfb Modify parsing membership 2020-05-06 01:23:19 +09:00
taizan-hokuto
9aa35b9756 Merge tag 'v0.0.7' into develop
v0.0.7
2020-05-05 22:59:16 +09:00
taizan-hokuto
f0a1a509a0 Merge branch 'release/v0.0.7' 2020-05-05 22:59:16 +09:00
taizan-hokuto
5ebca605ac Increment version 2020-05-05 22:58:29 +09:00
taizan-hokuto
3826b32ab9 Merge tag 'membership_renderer' into develop 2020-05-05 22:51:16 +09:00
taizan-hokuto
a46c82d3c0 Merge branch 'hotfix/membership_renderer' 2020-05-05 22:51:16 +09:00
taizan-hokuto
206d052907 Modify parsing membership 2020-05-05 22:47:12 +09:00
taizan-hokuto
141d7a9299 Merge tag 'termination' into develop 2020-05-05 21:18:46 +09:00
taizan-hokuto
04457eaa5c Merge branch 'hotfix/termination' 2020-05-05 21:18:46 +09:00
taizan-hokuto
bd32c75833 Modify termination 2020-05-05 21:16:06 +09:00
taizan-hokuto
84bae4ad2a Modify bytes combination 2020-04-18 00:55:56 +09:00
taizan-hokuto
d72608bf0a Merge tag 'json_decode_error' into develop
v0.0.6.6
2020-03-14 09:43:37 +09:00
taizan-hokuto
56bf721330 Merge tag 'argparse' into develop
v0.0.6.5
2020-03-10 01:58:25 +09:00
taizan-hokuto
725af25d81 Merge tag 'v0.0.6.4' into develop
v0.0.6.4
2020-03-08 23:43:01 +09:00
12 changed files with 2174 additions and 242 deletions

View File

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

View File

@@ -11,8 +11,8 @@ from queue import Queue
from .buffer import Buffer from .buffer import Buffer
from ..parser.live import Parser from ..parser.live import Parser
from .. import config from .. import config
from ..exceptions import ChatParseException,IllegalFunctionCall from ..exceptions import ChatParseException, IllegalFunctionCall
from ..paramgen import liveparam, arcparam from ..paramgen import liveparam, arcparam
from ..processors.default.processor import DefaultProcessor from ..processors.default.processor import DefaultProcessor
from ..processors.combinator import Combinator from ..processors.combinator import Combinator
@@ -72,22 +72,20 @@ class LiveChat:
''' '''
_setup_finished = False _setup_finished = False
#チャット監視中のListenerのリスト
_listeners = []
def __init__(self, video_id, def __init__(self, video_id,
seektime = 0, seektime=0,
processor = DefaultProcessor(), processor=DefaultProcessor(),
buffer = None, buffer=None,
interruptable = True, interruptable=True,
callback = None, callback=None,
done_callback = None, done_callback=None,
direct_mode = False, direct_mode=False,
force_replay = False, force_replay=False,
topchat_only = False, topchat_only=False,
logger = config.logger(__name__) logger=config.logger(__name__)
): ):
self.video_id = video_id self.video_id = video_id
self.seektime = seektime self.seektime = seektime
if isinstance(processor, tuple): if isinstance(processor, tuple):
self.processor = Combinator(processor) self.processor = Combinator(processor)
@@ -98,55 +96,49 @@ class LiveChat:
self._done_callback = done_callback self._done_callback = done_callback
self._executor = ThreadPoolExecutor(max_workers=2) self._executor = ThreadPoolExecutor(max_workers=2)
self._direct_mode = direct_mode self._direct_mode = direct_mode
self._is_alive = True self._is_alive = True
self._is_replay = force_replay self._is_replay = force_replay
self._parser = Parser(is_replay = self._is_replay) self._parser = Parser(is_replay=self._is_replay)
self._pauser = Queue() self._pauser = Queue()
self._pauser.put_nowait(None) self._pauser.put_nowait(None)
self._setup()
self._first_fetch = True self._first_fetch = True
self._fetch_url = "live_chat/get_live_chat?continuation=" self._fetch_url = "live_chat/get_live_chat?continuation="
self._topchat_only = topchat_only self._topchat_only = topchat_only
self._logger = logger self._logger = logger
LiveChat._logger = logger if interruptable:
if not LiveChat._setup_finished: signal.signal(signal.SIGINT, lambda a, b: self.terminate())
LiveChat._setup_finished = True self._setup()
if interruptable:
signal.signal(signal.SIGINT, (lambda a, b:
(LiveChat.shutdown(None,signal.SIGINT,b))
))
LiveChat._listeners.append(self)
def _setup(self): def _setup(self):
#direct modeがTrueでcallback未設定の場合例外発生。 # direct modeがTrueでcallback未設定の場合例外発生。
if self._direct_mode: if self._direct_mode:
if self._callback is None: if self._callback is None:
raise IllegalFunctionCall( raise IllegalFunctionCall(
"When direct_mode=True, callback parameter is required.") "When direct_mode=True, callback parameter is required.")
else: else:
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成 # direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
if self._buffer is None: if self._buffer is None:
self._buffer = Buffer(maxsize = 20) self._buffer = Buffer(maxsize=20)
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成 # callbackが指定されている場合はcallbackを呼ぶループタスクを作成
if self._callback is None: if self._callback is None:
pass pass
else: else:
#callbackを呼ぶループタスクの開始 # callbackを呼ぶループタスクの開始
self._executor.submit(self._callback_loop,self._callback) self._executor.submit(self._callback_loop, self._callback)
#_listenループタスクの開始 # _listenループタスクの開始
listen_task = self._executor.submit(self._startlisten) listen_task = self._executor.submit(self._startlisten)
#add_done_callbackの登録 # add_done_callbackの登録
if self._done_callback is None: if self._done_callback is None:
listen_task.add_done_callback(self.finish) listen_task.add_done_callback(self.finish)
else: else:
listen_task.add_done_callback(self._done_callback) listen_task.add_done_callback(self._done_callback)
def _startlisten(self): def _startlisten(self):
time.sleep(0.1) #sleep shortly to prohibit skipping fetching data time.sleep(0.1) # sleep shortly to prohibit skipping fetching data
"""Fetch first continuation parameter, """Fetch first continuation parameter,
create and start _listen loop. create and start _listen loop.
""" """
initial_continuation = liveparam.getparam(self.video_id,3) initial_continuation = liveparam.getparam(self.video_id, 3)
self._listen(initial_continuation) self._listen(initial_continuation)
def _listen(self, continuation): def _listen(self, continuation):
@@ -164,18 +156,19 @@ class LiveChat:
continuation = self._check_pause(continuation) continuation = self._check_pause(continuation)
contents = self._get_contents( contents = self._get_contents(
continuation, session, headers) continuation, session, headers)
metadata, chatdata = self._parser.parse(contents) metadata, chatdata = self._parser.parse(contents)
timeout = metadata['timeoutMs']/1000 timeout = metadata['timeoutMs']/1000
chat_component = { chat_component = {
"video_id" : self.video_id, "video_id": self.video_id,
"timeout" : timeout, "timeout": timeout,
"chatdata" : chatdata "chatdata": chatdata
} }
time_mark =time.time() time_mark = time.time()
if self._direct_mode: if self._direct_mode:
processed_chat = self.processor.process([chat_component]) processed_chat = self.processor.process(
if isinstance(processed_chat,tuple): [chat_component])
if isinstance(processed_chat, tuple):
self._callback(*processed_chat) self._callback(*processed_chat)
else: else:
self._callback(processed_chat) self._callback(processed_chat)
@@ -187,7 +180,7 @@ class LiveChat:
except ChatParseException as e: except ChatParseException as e:
self._logger.debug(f"[{self.video_id}]{str(e)}") self._logger.debug(f"[{self.video_id}]{str(e)}")
return return
except (TypeError , json.JSONDecodeError) : except (TypeError, json.JSONDecodeError):
self._logger.error(f"{traceback.format_exc(limit = -1)}") self._logger.error(f"{traceback.format_exc(limit = -1)}")
return return
@@ -202,7 +195,7 @@ class LiveChat:
''' '''
self._pauser.put_nowait(None) self._pauser.put_nowait(None)
if not self._is_replay: if not self._is_replay:
continuation = liveparam.getparam(self.video_id,3) continuation = liveparam.getparam(self.video_id, 3)
return continuation return continuation
def _get_contents(self, continuation, session, headers): def _get_contents(self, continuation, session, headers):
@@ -225,7 +218,7 @@ class LiveChat:
self._fetch_url = "live_chat_replay/get_live_chat_replay?continuation=" self._fetch_url = "live_chat_replay/get_live_chat_replay?continuation="
continuation = arcparam.getparam( continuation = arcparam.getparam(
self.video_id, self.seektime, self._topchat_only) self.video_id, self.seektime, self._topchat_only)
livechat_json = ( self._get_livechat_json( livechat_json = (self._get_livechat_json(
continuation, session, headers)) continuation, session, headers))
reload_continuation = self._parser.reload_continuation( reload_continuation = self._parser.reload_continuation(
self._parser.get_contents(livechat_json)) self._parser.get_contents(livechat_json))
@@ -244,23 +237,23 @@ class LiveChat:
continuation = urllib.parse.quote(continuation) continuation = urllib.parse.quote(continuation)
livechat_json = None livechat_json = None
status_code = 0 status_code = 0
url =f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1" url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
for _ in range(MAX_RETRY + 1): for _ in range(MAX_RETRY + 1):
with session.get(url ,headers = headers) as resp: with session.get(url, headers=headers) as resp:
try: try:
text = resp.text text = resp.text
livechat_json = json.loads(text) livechat_json = json.loads(text)
break break
except json.JSONDecodeError : except json.JSONDecodeError:
time.sleep(1) time.sleep(1)
continue continue
else: else:
self._logger.error(f"[{self.video_id}]" self._logger.error(f"[{self.video_id}]"
f"Exceeded retry count. status_code={status_code}") f"Exceeded retry count. status_code={status_code}")
return None return None
return livechat_json return livechat_json
def _callback_loop(self,callback): def _callback_loop(self, callback):
""" コンストラクタでcallbackを指定している場合、バックグラウンドで """ コンストラクタでcallbackを指定している場合、バックグラウンドで
callbackに指定された関数に一定間隔でチャットデータを投げる。 callbackに指定された関数に一定間隔でチャットデータを投げる。
@@ -286,7 +279,7 @@ class LiveChat:
""" """
if self._callback is None: if self._callback is None:
items = self._buffer.get() items = self._buffer.get()
return self.processor.process(items) return self.processor.process(items)
raise IllegalFunctionCall( raise IllegalFunctionCall(
"既にcallbackを登録済みのため、get()は実行できません。") "既にcallbackを登録済みのため、get()は実行できません。")
@@ -308,7 +301,7 @@ class LiveChat:
def is_alive(self): def is_alive(self):
return self._is_alive return self._is_alive
def finish(self,sender): def finish(self, sender):
'''Listener終了時のコールバック''' '''Listener終了時のコールバック'''
try: try:
self.terminate() self.terminate()
@@ -319,14 +312,7 @@ class LiveChat:
''' '''
Listenerを終了する。 Listenerを終了する。
''' '''
self._is_alive = False if self.is_alive():
if self._direct_mode == False: self._is_alive = False
#bufferにダミーオブジェクトを入れてis_alive()を判定させる self._buffer.put({})
self._buffer.put({'chatdata':'','timeout':0}) self._logger.info(f'[{self.video_id}]終了しました')
self._logger.info(f'[{self.video_id}]finished.')
@classmethod
def shutdown(cls, event, sig = None, handler=None):
cls._logger.debug("shutdown...")
for t in LiveChat._listeners:
t._is_alive = False

View File

@@ -12,6 +12,7 @@ Author: taizan-hokuto (2019) @taizan205
ver 0.0.1 2019.10.05 ver 0.0.1 2019.10.05
''' '''
def _gen_vid(video_id): def _gen_vid(video_id):
"""generate video_id parameter. """generate video_id parameter.
Parameter Parameter
@@ -23,7 +24,7 @@ def _gen_vid(video_id):
bytes : base64 encoded video_id parameter. bytes : base64 encoded video_id parameter.
""" """
header_magic = b'\x0A\x0F\x1A\x0D\x0A' header_magic = b'\x0A\x0F\x1A\x0D\x0A'
header_id = video_id.encode() header_id = video_id.encode()
header_sep_1 = b'\x1A\x13\xEA\xA8\xDD\xB9\x01\x0D\x0A\x0B' header_sep_1 = b'\x1A\x13\xEA\xA8\xDD\xB9\x01\x0D\x0A\x0B'
header_terminator = b'\x20\x01' header_terminator = b'\x20\x01'
@@ -40,42 +41,46 @@ def _gen_vid(video_id):
b64enc(reduce(lambda x, y: x+y, item)).decode() b64enc(reduce(lambda x, y: x+y, item)).decode()
).encode() ).encode()
def _nval(val): def _nval(val):
"""convert value to byte array""" """convert value to byte array"""
if val<0: raise ValueError if val < 0:
raise ValueError
buf = b'' buf = b''
while val >> 7: while val >> 7:
m = val & 0xFF | 0x80 m = val & 0xFF | 0x80
buf += m.to_bytes(1,'big') buf += m.to_bytes(1, 'big')
val >>= 7 val >>= 7
buf += val.to_bytes(1,'big') buf += val.to_bytes(1, 'big')
return buf return buf
def _build(video_id, seektime, topchat_only): def _build(video_id, seektime, topchat_only):
switch_01 = b'\x04' if topchat_only else b'\x01' switch_01 = b'\x04' if topchat_only else b'\x01'
if seektime < 0: if seektime < 0:
times =_nval(0) times = _nval(0)
switch = b'\x04' switch = b'\x04'
elif seektime == 0: elif seektime == 0:
times =_nval(1) times = _nval(1)
switch = b'\x03' switch = b'\x03'
else: else:
times =_nval(int(seektime*1000000)) times = _nval(int(seektime*1000000))
switch = b'\x03' switch = b'\x03'
parity = b'\x00' parity = b'\x00'
header_magic= b'\xA2\x9D\xB0\xD3\x04' header_magic = b'\xA2\x9D\xB0\xD3\x04'
sep_0 = b'\x1A' sep_0 = b'\x1A'
vid = _gen_vid(video_id) vid = _gen_vid(video_id)
time_tag = b'\x28' time_tag = b'\x28'
timestamp1 = times timestamp1 = times
sep_1 = b'\x30\x00\x38\x00\x40\x00\x48' sep_1 = b'\x30\x00\x38\x00\x40\x00\x48'
sep_2 = b'\x52\x1C\x08\x00\x10\x00\x18\x00\x20\x00' sep_2 = b'\x52\x1C\x08\x00\x10\x00\x18\x00\x20\x00'
chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D\x40' chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D\x40'
sep_3 = b'\x00\x58\x03\x60' sep_3 = b'\x00\x58\x03\x60'
sep_4 = b'\x68' + parity + b'\x72\x04\x08' sep_4 = b'\x68' + parity + b'\x72\x04\x08'
sep_5 = b'\x10' + parity + b'\x78\x00' sep_5 = b'\x10' + parity + b'\x78\x00'
body = [
body = b''.join([
sep_0, sep_0,
_nval(len(vid)), _nval(len(vid)),
vid, vid,
@@ -90,18 +95,17 @@ def _build(video_id, seektime, topchat_only):
sep_4, sep_4,
switch_01, switch_01,
sep_5 sep_5
] ])
body = reduce(lambda x, y: x+y, body) return urllib.parse.quote(
b64enc(header_magic +
_nval(len(body)) +
body
).decode()
)
return urllib.parse.quote(
b64enc( header_magic +
_nval(len(body)) +
body
).decode()
)
def getparam(video_id, seektime = 0, topchat_only = False): def getparam(video_id, seektime=0, topchat_only=False):
''' '''
Parameter Parameter
--------- ---------

View File

@@ -11,6 +11,8 @@ Author: taizan-hokuto (2019) @taizan205
ver 0.0.1 2019.10.05 ver 0.0.1 2019.10.05
''' '''
def _gen_vid(video_id): def _gen_vid(video_id):
"""generate video_id parameter. """generate video_id parameter.
Parameter Parameter
@@ -22,11 +24,11 @@ def _gen_vid(video_id):
byte[] : base64 encoded video_id parameter. byte[] : base64 encoded video_id parameter.
""" """
header_magic = b'\x0A\x0F\x0A\x0D\x0A' header_magic = b'\x0A\x0F\x0A\x0D\x0A'
header_id = video_id.encode() header_id = video_id.encode()
header_sep_1 = b'\x1A' header_sep_1 = b'\x1A'
header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A' header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A'
header_suburl = ('https://www.youtube.com/live_chat?v=' header_suburl = ('https://www.youtube.com/live_chat?v='
f'{video_id}&is_popout=1').encode() f'{video_id}&is_popout=1').encode()
header_terminator = b'\x20\x02' header_terminator = b'\x20\x02'
item = [ item = [
@@ -44,62 +46,66 @@ def _gen_vid(video_id):
b64enc(reduce(lambda x, y: x+y, item)).decode() b64enc(reduce(lambda x, y: x+y, item)).decode()
).encode() ).encode()
def _tzparity(video_id,times):
t=0 def _tzparity(video_id, times):
for i,s in enumerate(video_id): t = 0
for i, s in enumerate(video_id):
ss = ord(s) ss = ord(s)
if(ss % 2 == 0): if(ss % 2 == 0):
t += ss*(12-i) t += ss*(12-i)
else: else:
t ^= ss*i t ^= ss*i
return ((times^t) % 2).to_bytes(1,'big') return ((times ^ t) % 2).to_bytes(1, 'big')
def _nval(val): def _nval(val):
"""convert value to byte array""" """convert value to byte array"""
if val<0: raise ValueError if val < 0:
raise ValueError
buf = b'' buf = b''
while val >> 7: while val >> 7:
m = val & 0xFF | 0x80 m = val & 0xFF | 0x80
buf += m.to_bytes(1,'big') buf += m.to_bytes(1, 'big')
val >>= 7 val >>= 7
buf += val.to_bytes(1,'big') buf += val.to_bytes(1, 'big')
return buf return buf
def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only): def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
#_short_type2 # _short_type2
switch_01 = b'\x04' if topchat_only else b'\x01' switch_01 = b'\x04' if topchat_only else b'\x01'
parity = _tzparity(video_id, _ts1^_ts2^_ts3^_ts4^_ts5) parity = _tzparity(video_id, _ts1 ^ _ts2 ^ _ts3 ^ _ts4 ^ _ts5)
header_magic= b'\xD2\x87\xCC\xC8\x03' header_magic = b'\xD2\x87\xCC\xC8\x03'
sep_0 = b'\x1A' sep_0 = b'\x1A'
vid = _gen_vid(video_id) vid = _gen_vid(video_id)
time_tag = b'\x28' time_tag = b'\x28'
timestamp1 = _nval(_ts1) timestamp1 = _nval(_ts1)
sep_1 = b'\x30\x00\x38\x00\x40\x02\x4A' sep_1 = b'\x30\x00\x38\x00\x40\x02\x4A'
un_len = b'\x2B' un_len = b'\x2B'
sep_2 = b'\x08'+parity+b'\x10\x00\x18\x00\x20\x00' sep_2 = b'\x08'+parity+b'\x10\x00\x18\x00\x20\x00'
chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D' 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_3 = b'\x3A\x00\x40\x00\x4A'
sep_4_len = b'\x02' sep_4_len = b'\x02'
sep_4 = b'\x08\x01' sep_4 = b'\x08\x01'
ts_2_start = b'\x50' ts_2_start = b'\x50'
timestamp2 = _nval(_ts2) timestamp2 = _nval(_ts2)
ts_2_end = b'\x58' ts_2_end = b'\x58'
sep_5 = b'\x03' sep_5 = b'\x03'
ts_3_start = b'\x50' ts_3_start = b'\x50'
timestamp3 = _nval(_ts3) timestamp3 = _nval(_ts3)
ts_3_end = b'\x58' ts_3_end = b'\x58'
timestamp4 = _nval(_ts4) timestamp4 = _nval(_ts4)
sep_6 = b'\x68' sep_6 = b'\x68'
#switch # switch
sep_7 = b'\x82\x01\x04\x08' sep_7 = b'\x82\x01\x04\x08'
#switch # switch
sep_8 = b'\x10\x00' sep_8 = b'\x10\x00'
sep_9 = b'\x88\x01\x00\xA0\x01' sep_9 = b'\x88\x01\x00\xA0\x01'
timestamp5 = _nval(_ts5) timestamp5 = _nval(_ts5)
body = [ body = b''.join([
sep_0, sep_0,
_nval(len(vid)), _nval(len(vid)),
vid, vid,
@@ -121,37 +127,35 @@ def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
ts_3_end, ts_3_end,
timestamp4, timestamp4,
sep_6, sep_6,
switch_01,# switch_01,
sep_7, sep_7,
switch_01,# switch_01,
sep_8, sep_8,
sep_9, sep_9,
timestamp5 timestamp5
] ])
body = reduce(lambda x, y: x+y, body) return urllib.parse.quote(
b64enc(header_magic +
return urllib.parse.quote( _nval(len(body)) +
b64enc( header_magic + body
_nval(len(body)) + ).decode()
body )
).decode()
)
def _times(past_sec): def _times(past_sec):
n = int(time.time()) n = int(time.time())
_ts1= n - random.uniform(0,1*3) _ts1 = n - random.uniform(0, 1*3)
_ts2= n - random.uniform(0.01,0.99) _ts2 = n - random.uniform(0.01, 0.99)
_ts3= n - past_sec + random.uniform(0,1) _ts3 = n - past_sec + random.uniform(0, 1)
_ts4= n - random.uniform(10*60,60*60) _ts4 = n - random.uniform(10*60, 60*60)
_ts5= n - random.uniform(0.01,0.99) _ts5 = n - random.uniform(0.01, 0.99)
return list(map(lambda x:int(x*1000000),[_ts1,_ts2,_ts3,_ts4,_ts5])) return list(map(lambda x: int(x*1000000), [_ts1, _ts2, _ts3, _ts4, _ts5]))
def getparam(video_id, past_sec = 0, topchat_only = False): def getparam(video_id, past_sec=0, topchat_only=False):
''' '''
Parameter Parameter
--------- ---------
@@ -160,5 +164,4 @@ def getparam(video_id, past_sec = 0, topchat_only = False):
topchat_only : bool topchat_only : bool
if True, fetch only 'top chat' if True, fetch only 'top chat'
''' '''
return _build(video_id,*_times(past_sec),topchat_only) return _build(video_id, *_times(past_sec), topchat_only)

View File

@@ -4,17 +4,19 @@ from .renderer.textmessage import LiveChatTextMessageRenderer
from .renderer.paidmessage import LiveChatPaidMessageRenderer from .renderer.paidmessage import LiveChatPaidMessageRenderer
from .renderer.paidsticker import LiveChatPaidStickerRenderer from .renderer.paidsticker import LiveChatPaidStickerRenderer
from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
from .renderer.membership import LiveChatMembershipItemRenderer
from .. chat_processor import ChatProcessor from .. chat_processor import ChatProcessor
from ... import config from ... import config
logger = config.logger(__name__) logger = config.logger(__name__)
class CompatibleProcessor(ChatProcessor): class CompatibleProcessor(ChatProcessor):
def process(self, chat_components: list): def process(self, chat_components: list):
chatlist = [] chatlist = []
timeout = 0 timeout = 0
ret={} ret = {}
ret["kind"] = "youtube#liveChatMessageListResponse" ret["kind"] = "youtube#liveChatMessageListResponse"
ret["etag"] = "" ret["etag"] = ""
ret["nextPageToken"] = "" ret["nextPageToken"] = ""
@@ -24,19 +26,23 @@ class CompatibleProcessor(ChatProcessor):
timeout += chat_component.get('timeout', 0) timeout += chat_component.get('timeout', 0)
chatdata = chat_component.get('chatdata') chatdata = chat_component.get('chatdata')
if chatdata is None: break if chatdata is None:
break
for action in chatdata: for action in chatdata:
if action is None: continue if action is None:
if action.get('addChatItemAction') is None: continue continue
if action['addChatItemAction'].get('item') is None: continue if action.get('addChatItemAction') is None:
continue
if action['addChatItemAction'].get('item') is None:
continue
chat = self.parse(action) chat = self.parse(action)
if chat: if chat:
chatlist.append(chat) chatlist.append(chat)
ret["pollingIntervalMillis"] = int(timeout*1000) ret["pollingIntervalMillis"] = int(timeout*1000)
ret["pageInfo"]={ ret["pageInfo"] = {
"totalResults":len(chatlist), "totalResults": len(chatlist),
"resultsPerPage":len(chatlist), "resultsPerPage": len(chatlist),
} }
ret["items"] = chatlist ret["items"] = chatlist
@@ -47,8 +53,9 @@ class CompatibleProcessor(ChatProcessor):
action = sitem.get("addChatItemAction") action = sitem.get("addChatItemAction")
if action: if action:
item = action.get("item") item = action.get("item")
if item is None: return None if item is None:
rd={} return None
rd = {}
try: try:
renderer = self.get_renderer(item) renderer = self.get_renderer(item)
if renderer == None: if renderer == None:
@@ -57,9 +64,9 @@ class CompatibleProcessor(ChatProcessor):
rd["kind"] = "youtube#liveChatMessage" rd["kind"] = "youtube#liveChatMessage"
rd["etag"] = "" rd["etag"] = ""
rd["id"] = 'LCC.' + renderer.get_id() rd["id"] = 'LCC.' + renderer.get_id()
rd["snippet"] = renderer.get_snippet() rd["snippet"] = renderer.get_snippet()
rd["authorDetails"] = renderer.get_authordetails() rd["authorDetails"] = renderer.get_authordetails()
except (KeyError,TypeError,AttributeError) as e: except (KeyError, TypeError, AttributeError) as e:
logger.error(f"Error: {str(type(e))}-{str(e)}") logger.error(f"Error: {str(type(e))}-{str(e)}")
logger.error(f"item: {sitem}") logger.error(f"item: {sitem}")
return None return None
@@ -71,11 +78,12 @@ class CompatibleProcessor(ChatProcessor):
renderer = LiveChatTextMessageRenderer(item) renderer = LiveChatTextMessageRenderer(item)
elif item.get("liveChatPaidMessageRenderer"): elif item.get("liveChatPaidMessageRenderer"):
renderer = LiveChatPaidMessageRenderer(item) renderer = LiveChatPaidMessageRenderer(item)
elif item.get( "liveChatPaidStickerRenderer"): elif item.get("liveChatPaidStickerRenderer"):
renderer = LiveChatPaidStickerRenderer(item) renderer = LiveChatPaidStickerRenderer(item)
elif item.get("liveChatLegacyPaidMessageRenderer"): elif item.get("liveChatLegacyPaidMessageRenderer"):
renderer = LiveChatLegacyPaidMessageRenderer(item) renderer = LiveChatLegacyPaidMessageRenderer(item)
elif item.get("liveChatMembershipItemRenderer"):
renderer = LiveChatMembershipItemRenderer(item)
else: else:
renderer = None renderer = None
return renderer return renderer

View File

@@ -0,0 +1,40 @@
from .base import BaseRenderer
class LiveChatMembershipItemRenderer(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 = ''.join([mes.get("text", "") for mes in renderer["headerSubtext"]["runs"]])
return message, [message]

View File

@@ -4,12 +4,15 @@ from .renderer.textmessage import LiveChatTextMessageRenderer
from .renderer.paidmessage import LiveChatPaidMessageRenderer from .renderer.paidmessage import LiveChatPaidMessageRenderer
from .renderer.paidsticker import LiveChatPaidStickerRenderer from .renderer.paidsticker import LiveChatPaidStickerRenderer
from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
from .renderer.membership import LiveChatMembershipItemRenderer
from .. chat_processor import ChatProcessor from .. chat_processor import ChatProcessor
from ... import config from ... import config
logger = config.logger(__name__) logger = config.logger(__name__)
class Chatdata: class Chatdata:
def __init__(self,chatlist:list, timeout:float): def __init__(self, chatlist: list, timeout: float):
self.items = chatlist self.items = chatlist
self.interval = timeout self.interval = timeout
@@ -25,6 +28,7 @@ class Chatdata:
return return
await asyncio.sleep(self.interval/len(self.items)) await asyncio.sleep(self.interval/len(self.items))
class DefaultProcessor(ChatProcessor): class DefaultProcessor(ChatProcessor):
def process(self, chat_components: list): def process(self, chat_components: list):
@@ -35,24 +39,27 @@ class DefaultProcessor(ChatProcessor):
for component in chat_components: for component in chat_components:
timeout += component.get('timeout', 0) timeout += component.get('timeout', 0)
chatdata = component.get('chatdata') chatdata = component.get('chatdata')
if chatdata is None: continue if chatdata is None:
continue
for action in chatdata: for action in chatdata:
if action is None: continue if action is None:
if action.get('addChatItemAction') is None: continue continue
if action['addChatItemAction'].get('item') is None: continue if action.get('addChatItemAction') is None:
continue
if action['addChatItemAction'].get('item') is None:
continue
chat = self._parse(action) chat = self._parse(action)
if chat: if chat:
chatlist.append(chat) chatlist.append(chat)
return Chatdata(chatlist, float(timeout)) return Chatdata(chatlist, float(timeout))
def _parse(self, sitem): def _parse(self, sitem):
action = sitem.get("addChatItemAction") action = sitem.get("addChatItemAction")
if action: if action:
item = action.get("item") item = action.get("item")
if item is None: return None if item is None:
return None
try: try:
renderer = self._get_renderer(item) renderer = self._get_renderer(item)
if renderer == None: if renderer == None:
@@ -60,7 +67,7 @@ class DefaultProcessor(ChatProcessor):
renderer.get_snippet() renderer.get_snippet()
renderer.get_authordetails() renderer.get_authordetails()
except (KeyError,TypeError) as e: except (KeyError, TypeError) as e:
logger.error(f"{str(type(e))}-{str(e)} sitem:{str(sitem)}") logger.error(f"{str(type(e))}-{str(e)} sitem:{str(sitem)}")
return None return None
return renderer return renderer
@@ -70,10 +77,12 @@ class DefaultProcessor(ChatProcessor):
renderer = LiveChatTextMessageRenderer(item) renderer = LiveChatTextMessageRenderer(item)
elif item.get("liveChatPaidMessageRenderer"): elif item.get("liveChatPaidMessageRenderer"):
renderer = LiveChatPaidMessageRenderer(item) renderer = LiveChatPaidMessageRenderer(item)
elif item.get( "liveChatPaidStickerRenderer"): elif item.get("liveChatPaidStickerRenderer"):
renderer = LiveChatPaidStickerRenderer(item) renderer = LiveChatPaidStickerRenderer(item)
elif item.get("liveChatLegacyPaidMessageRenderer"): elif item.get("liveChatLegacyPaidMessageRenderer"):
renderer = LiveChatLegacyPaidMessageRenderer(item) renderer = LiveChatLegacyPaidMessageRenderer(item)
elif item.get("liveChatMembershipItemRenderer"):
renderer = LiveChatMembershipItemRenderer(item)
else: else:
renderer = None renderer = None
return renderer return renderer

View File

@@ -0,0 +1,15 @@
from .base import BaseRenderer
class LiveChatMembershipItemRenderer(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 = ''.join([mes.get("text", "") for mes in renderer["headerSubtext"]["runs"]])
return message, [message]

View File

@@ -70,7 +70,8 @@ def check_duplicate_offset(chatdata):
if is_duplicate(i,i+1)] if is_duplicate(i,i+1)]
def remove_duplicate_head(blocks): def remove_duplicate_head(blocks):
if len(blocks) == 1 : return blocks if len(blocks) == 0 or len(blocks) == 1:
return blocks
def is_duplicate_head(index): def is_duplicate_head(index):
@@ -97,7 +98,8 @@ def remove_duplicate_head(blocks):
return ret return ret
def remove_duplicate_tail(blocks): def remove_duplicate_tail(blocks):
if len(blocks) == 1 : return blocks if len(blocks) == 0 or len(blocks) == 1:
return blocks
def is_duplicate_tail(index): def is_duplicate_tail(index):
if len(blocks[index].chat_data) == 0: if len(blocks[index].chat_data) == 0:
@@ -126,7 +128,8 @@ def remove_overlap(blocks):
Align the last offset of each block to the first offset Align the last offset of each block to the first offset
of next block (equals `end` offset of each block). of next block (equals `end` offset of each block).
""" """
if len(blocks) == 1 : return blocks if len(blocks) == 0 or len(blocks) == 1:
return blocks
for block in blocks: for block in blocks:
if block.is_last: if block.is_last:

View File

@@ -40,10 +40,11 @@ class Extractor:
return self return self
def _set_block_end(self): def _set_block_end(self):
for i in range(len(self.blocks)-1): if len(self.blocks) > 0:
self.blocks[i].end = self.blocks[i+1].first for i in range(len(self.blocks)-1):
self.blocks[-1].end = self.duration*1000 self.blocks[i].end = self.blocks[i+1].first
self.blocks[-1].is_last =True self.blocks[-1].end = self.duration*1000
self.blocks[-1].is_last =True
return self return self
def _remove_overlap(self): def _remove_overlap(self):

View File

@@ -1,10 +1,11 @@
import json import json
import pytest import pytest
import asyncio,aiohttp import asyncio
import aiohttp
from pytchat.parser.live import Parser from pytchat.parser.live import Parser
from pytchat.processors.compatible.processor import CompatibleProcessor from pytchat.processors.compatible.processor import CompatibleProcessor
from pytchat.exceptions import ( from pytchat.exceptions import (
NoLivechatRendererException,NoYtinitialdataException, NoLivechatRendererException, NoYtinitialdataException,
ResponseContextError, NoContentsException) ResponseContextError, NoContentsException)
from pytchat.processors.compatible.renderer.textmessage import LiveChatTextMessageRenderer from pytchat.processors.compatible.renderer.textmessage import LiveChatTextMessageRenderer
@@ -14,6 +15,7 @@ from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaid
parser = Parser(is_replay=False) parser = Parser(is_replay=False)
def test_textmessage(mocker): def test_textmessage(mocker):
'''api互換processorのテスト通常テキストメッセージ''' '''api互換processorのテスト通常テキストメッセージ'''
processor = CompatibleProcessor() processor = CompatibleProcessor()
@@ -22,16 +24,16 @@ def test_textmessage(mocker):
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id": "",
"timeout" : 7, "timeout": 7,
"chatdata" : chatdata "chatdata": chatdata
} }
ret = processor.process([data]) ret = processor.process([data])
assert ret["kind"]== "youtube#liveChatMessageListResponse" assert ret["kind"] == "youtube#liveChatMessageListResponse"
assert ret["pollingIntervalMillis"]==data["timeout"]*1000 assert ret["pollingIntervalMillis"] == data["timeout"]*1000
assert ret.keys() == { assert ret.keys() == {
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" "kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
} }
assert ret["pageInfo"].keys() == { assert ret["pageInfo"].keys() == {
"totalResults", "resultsPerPage" "totalResults", "resultsPerPage"
@@ -49,7 +51,8 @@ def test_textmessage(mocker):
'messageText' 'messageText'
} }
assert "LCC." in ret["items"][0]["id"] assert "LCC." in ret["items"][0]["id"]
assert ret["items"][0]["snippet"]["type"]=="textMessageEvent" assert ret["items"][0]["snippet"]["type"] == "textMessageEvent"
def test_newsponcer(mocker): def test_newsponcer(mocker):
'''api互換processorのテストメンバ新規登録''' '''api互換processorのテストメンバ新規登録'''
@@ -59,22 +62,22 @@ def test_newsponcer(mocker):
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id": "",
"timeout" : 7, "timeout": 7,
"chatdata" : chatdata "chatdata": chatdata
} }
ret = processor.process([data]) ret = processor.process([data])
assert ret["kind"]== "youtube#liveChatMessageListResponse" assert ret["kind"] == "youtube#liveChatMessageListResponse"
assert ret["pollingIntervalMillis"]==data["timeout"]*1000 assert ret["pollingIntervalMillis"] == data["timeout"]*1000
assert ret.keys() == { assert ret.keys() == {
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" "kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
} }
assert ret["pageInfo"].keys() == { assert ret["pageInfo"].keys() == {
"totalResults", "resultsPerPage" "totalResults", "resultsPerPage"
} }
assert ret["items"][0].keys() == { assert ret["items"][0].keys() == {
"kind", "etag", "id", "snippet","authorDetails" "kind", "etag", "id", "snippet", "authorDetails"
} }
assert ret["items"][0]["snippet"].keys() == { assert ret["items"][0]["snippet"].keys() == {
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage' 'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage'
@@ -84,7 +87,43 @@ def test_newsponcer(mocker):
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator' 'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator'
} }
assert "LCC." in ret["items"][0]["id"] assert "LCC." in ret["items"][0]["id"]
assert ret["items"][0]["snippet"]["type"]=="newSponsorEvent" assert ret["items"][0]["snippet"]["type"] == "newSponsorEvent"
def test_newsponcer_rev(mocker):
'''api互換processorのテストメンバ新規登録'''
processor = CompatibleProcessor()
_json = _open_file("tests/testdata/compatible/newSponsor_rev.json")
_, chatdata = parser.parse(parser.get_contents(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): def test_superchat(mocker):
@@ -95,16 +134,16 @@ def test_superchat(mocker):
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id": "",
"timeout" : 7, "timeout": 7,
"chatdata" : chatdata "chatdata": chatdata
} }
ret = processor.process([data]) ret = processor.process([data])
assert ret["kind"]== "youtube#liveChatMessageListResponse" assert ret["kind"] == "youtube#liveChatMessageListResponse"
assert ret["pollingIntervalMillis"]==data["timeout"]*1000 assert ret["pollingIntervalMillis"] == data["timeout"]*1000
assert ret.keys() == { assert ret.keys() == {
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items" "kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
} }
assert ret["pageInfo"].keys() == { assert ret["pageInfo"].keys() == {
"totalResults", "resultsPerPage" "totalResults", "resultsPerPage"
@@ -122,7 +161,8 @@ def test_superchat(mocker):
'amountMicros', 'currency', 'amountDisplayString', 'tier', 'backgroundColor' 'amountMicros', 'currency', 'amountDisplayString', 'tier', 'backgroundColor'
} }
assert "LCC." in ret["items"][0]["id"] assert "LCC." in ret["items"][0]["id"]
assert ret["items"][0]["snippet"]["type"]=="superChatEvent" assert ret["items"][0]["snippet"]["type"] == "superChatEvent"
def test_unregistered_currency(mocker): def test_unregistered_currency(mocker):
processor = CompatibleProcessor() processor = CompatibleProcessor()
@@ -132,14 +172,14 @@ def test_unregistered_currency(mocker):
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))) _, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
data = { data = {
"video_id" : "", "video_id": "",
"timeout" : 7, "timeout": 7,
"chatdata" : chatdata "chatdata": chatdata
} }
ret = processor.process([data]) ret = processor.process([data])
assert ret["items"][0]["snippet"]["superChatDetails"]["currency"] == "[UNREGISTERD]" assert ret["items"][0]["snippet"]["superChatDetails"]["currency"] == "[UNREGISTERD]"
def _open_file(path): def _open_file(path):
with open(path,mode ='r',encoding = 'utf-8') as f: with open(path, mode='r', encoding='utf-8') as f:
return f.read() return f.read()

File diff suppressed because it is too large Load Diff