Compare commits

...

17 Commits

Author SHA1 Message Date
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
a46c82d3c0 Merge branch 'hotfix/membership_renderer' 2020-05-05 22:51:16 +09:00
taizan-hokuto
3826b32ab9 Merge tag 'membership_renderer' into develop 2020-05-05 22:51:16 +09:00
taizan-hokuto
206d052907 Modify parsing membership 2020-05-05 22:47:12 +09:00
taizan-hokuto
04457eaa5c Merge branch 'hotfix/termination' 2020-05-05 21:18:46 +09:00
taizan-hokuto
141d7a9299 Merge tag 'termination' into develop 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
3243d69d7a Merge branch 'hotfix/json_decode_error' 2020-03-14 09:43:37 +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
6e1b735ebc Increment version 2020-03-14 09:42:53 +09:00
taizan-hokuto
c54481dad5 Add header html and show progress 2020-03-14 09:26:28 +09:00
taizan-hokuto
78604c84d4 Fix testdata path separator 2020-03-14 08:16:19 +09:00
taizan-hokuto
21d93613a2 Handling JSONDecodeError 2020-03-14 08:00:31 +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
16 changed files with 2229 additions and 259 deletions

View File

@@ -7,10 +7,10 @@ pytchat is a python library for fetching youtube live chat.
pytchat is a python library for fetching youtube live chat
without using youtube api, Selenium or BeautifulSoup.
pytchatはAPIを使わずにYouTubeチャットを取得するための軽量pythonライブラリです。
pytchatはAPIを使わずにYouTubeチャットを取得するためのpythonライブラリです。
Other features:
+ Customizable chat data processors including youtube api compatible one.
+ Customizable [chat data processors](https://github.com/taizan-hokuto/pytchat/wiki/ChatProcessor) including youtube api compatible one.
+ Available on asyncio context.
+ Quick fetching of initial chat data by generating continuation params
instead of web scraping.

View File

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

View File

@@ -22,9 +22,10 @@ def main():
# Arguments
parser = argparse.ArgumentParser(description=f'pytchat v{__version__}')
parser.add_argument('-v', f'--{Arguments.Name.VIDEO}', type=str,
help='Video IDs separated by commas without space')
help='Video IDs separated by commas without space.\n'
'If ID starts with a hyphen (-), enclose the ID in square brackets.')
parser.add_argument('-o', f'--{Arguments.Name.OUTPUT}', type=str,
help='Output directory (end with "/")', default='./')
help='Output directory (end with "/"). default="./"', default='./')
parser.add_argument(f'--{Arguments.Name.VERSION}', action='store_true',
help='Settings version')
Arguments(parser.parse_args().__dict__)
@@ -43,11 +44,17 @@ def main():
f" video_id: {video_id}\n"
f" channel: {info.get_channel_name()}\n"
f" title: {info.get_title()}")
path = Path(Arguments().output+video_id+'.html')
print(f"output path: {path.resolve()}")
Extractor(video_id,
processor = HTMLArchiver(Arguments().output+video_id+'.html')
processor = HTMLArchiver(Arguments().output+video_id+'.html'),
callback = _disp_progress
).extract()
print("Extraction end.\n")
print("\nExtraction end.\n")
except (InvalidVideoIdException, NoContentsException) as e:
print(e)
return
parser.print_help()
def _disp_progress(a,b):
print('.',end="",flush=True)

View File

@@ -11,7 +11,7 @@ from queue import Queue
from .buffer import Buffer
from ..parser.live import Parser
from .. import config
from ..exceptions import ChatParseException,IllegalFunctionCall
from ..exceptions import ChatParseException, IllegalFunctionCall
from ..paramgen import liveparam, arcparam
from ..processors.default.processor import DefaultProcessor
from ..processors.combinator import Combinator
@@ -72,20 +72,18 @@ class LiveChat:
'''
_setup_finished = False
#チャット監視中のListenerのリスト
_listeners = []
def __init__(self, video_id,
seektime = 0,
processor = DefaultProcessor(),
buffer = None,
interruptable = True,
callback = None,
done_callback = None,
direct_mode = False,
force_replay = False,
topchat_only = False,
logger = config.logger(__name__)
seektime=0,
processor=DefaultProcessor(),
buffer=None,
interruptable=True,
callback=None,
done_callback=None,
direct_mode=False,
force_replay=False,
topchat_only=False,
logger=config.logger(__name__)
):
self.video_id = video_id
self.seektime = seektime
@@ -100,53 +98,47 @@ class LiveChat:
self._direct_mode = direct_mode
self._is_alive = True
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.put_nowait(None)
self._setup()
self._first_fetch = True
self._fetch_url = "live_chat/get_live_chat?continuation="
self._topchat_only = topchat_only
self._logger = logger
LiveChat._logger = logger
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)
signal.signal(signal.SIGINT, lambda a, b: self.terminate())
self._setup()
def _setup(self):
#direct modeがTrueでcallback未設定の場合例外発生。
# direct modeがTrueでcallback未設定の場合例外発生。
if self._direct_mode:
if self._callback is None:
raise IllegalFunctionCall(
"When direct_mode=True, callback parameter is required.")
else:
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
# direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
if self._buffer is None:
self._buffer = Buffer(maxsize = 20)
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成
self._buffer = Buffer(maxsize=20)
# callbackが指定されている場合はcallbackを呼ぶループタスクを作成
if self._callback is None:
pass
else:
#callbackを呼ぶループタスクの開始
self._executor.submit(self._callback_loop,self._callback)
#_listenループタスクの開始
# callbackを呼ぶループタスクの開始
self._executor.submit(self._callback_loop, self._callback)
# _listenループタスクの開始
listen_task = self._executor.submit(self._startlisten)
#add_done_callbackの登録
# 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):
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,
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)
def _listen(self, continuation):
@@ -168,14 +160,15 @@ class LiveChat:
timeout = metadata['timeoutMs']/1000
chat_component = {
"video_id" : self.video_id,
"timeout" : timeout,
"chatdata" : chatdata
"video_id": self.video_id,
"timeout": timeout,
"chatdata": chatdata
}
time_mark =time.time()
time_mark = time.time()
if self._direct_mode:
processed_chat = self.processor.process([chat_component])
if isinstance(processed_chat,tuple):
processed_chat = self.processor.process(
[chat_component])
if isinstance(processed_chat, tuple):
self._callback(*processed_chat)
else:
self._callback(processed_chat)
@@ -187,7 +180,7 @@ class LiveChat:
except ChatParseException as e:
self._logger.debug(f"[{self.video_id}]{str(e)}")
return
except (TypeError , json.JSONDecodeError) :
except (TypeError, json.JSONDecodeError):
self._logger.error(f"{traceback.format_exc(limit = -1)}")
return
@@ -202,7 +195,7 @@ class LiveChat:
'''
self._pauser.put_nowait(None)
if not self._is_replay:
continuation = liveparam.getparam(self.video_id,3)
continuation = liveparam.getparam(self.video_id, 3)
return continuation
def _get_contents(self, continuation, session, headers):
@@ -225,7 +218,7 @@ class LiveChat:
self._fetch_url = "live_chat_replay/get_live_chat_replay?continuation="
continuation = arcparam.getparam(
self.video_id, self.seektime, self._topchat_only)
livechat_json = ( self._get_livechat_json(
livechat_json = (self._get_livechat_json(
continuation, session, headers))
reload_continuation = self._parser.reload_continuation(
self._parser.get_contents(livechat_json))
@@ -244,14 +237,14 @@ class LiveChat:
continuation = urllib.parse.quote(continuation)
livechat_json = None
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):
with session.get(url ,headers = headers) as resp:
with session.get(url, headers=headers) as resp:
try:
text = resp.text
livechat_json = json.loads(text)
break
except json.JSONDecodeError :
except json.JSONDecodeError:
time.sleep(1)
continue
else:
@@ -260,7 +253,7 @@ class LiveChat:
return None
return livechat_json
def _callback_loop(self,callback):
def _callback_loop(self, callback):
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
callbackに指定された関数に一定間隔でチャットデータを投げる。
@@ -308,7 +301,7 @@ class LiveChat:
def is_alive(self):
return self._is_alive
def finish(self,sender):
def finish(self, sender):
'''Listener終了時のコールバック'''
try:
self.terminate()
@@ -319,14 +312,7 @@ class LiveChat:
'''
Listenerを終了する。
'''
if self.is_alive():
self._is_alive = False
if self._direct_mode == False:
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
self._buffer.put({'chatdata':'','timeout':0})
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
self._buffer.put({})
self._logger.info(f'[{self.video_id}]終了しました')

View File

@@ -1,46 +1,52 @@
class ChatParseException(Exception):
'''
チャットデータをパースするライブラリが投げる例外の基底クラス
Base exception thrown by the parser
'''
pass
class NoYtinitialdataException(ChatParseException):
'''
配信ページ内にチャットデータurlが見つからないときに投げる例外
Thrown when the video is not found.
'''
pass
class ResponseContextError(ChatParseException):
'''
配信ページでチャットデータ無効の時に投げる例外
Thrown when chat data is invalid.
'''
pass
class NoLivechatRendererException(ChatParseException):
'''
チャットデータのJSON中にlivechatRendererがない時に投げる例外
Thrown when livechatRenderer is missing in JSON.
'''
pass
class NoContentsException(ChatParseException):
'''
チャットデータのJSON中にContinuationContentsがない時に投げる例外
Thrown when ContinuationContents is missing in JSON.
'''
pass
class NoContinuationsException(ChatParseException):
'''
チャットデータのContinuationContents中にcontinuationがない時に投げる例外
Thrown when continuation is missing in ContinuationContents.
'''
pass
class IllegalFunctionCall(Exception):
'''
set_callback()を実行済みにもかかわらず
get()を呼び出した場合の例外
Thrown when get () is called even though
set_callback () has been executed.
'''
pass
class InvalidVideoIdException(Exception):
'''
Thrown when the video_id is not exist (VideoInfo).
'''
pass
class UnknownConnectionError(Exception):
pass

View File

@@ -12,6 +12,7 @@ Author: taizan-hokuto (2019) @taizan205
ver 0.0.1 2019.10.05
'''
def _gen_vid(video_id):
"""generate video_id parameter.
Parameter
@@ -40,31 +41,34 @@ def _gen_vid(video_id):
b64enc(reduce(lambda x, y: x+y, item)).decode()
).encode()
def _nval(val):
"""convert value to byte array"""
if val<0: raise ValueError
if val < 0:
raise ValueError
buf = b''
while val >> 7:
m = val & 0xFF | 0x80
buf += m.to_bytes(1,'big')
buf += m.to_bytes(1, 'big')
val >>= 7
buf += val.to_bytes(1,'big')
buf += val.to_bytes(1, 'big')
return buf
def _build(video_id, seektime, topchat_only):
switch_01 = b'\x04' if topchat_only else b'\x01'
if seektime < 0:
times =_nval(0)
times = _nval(0)
switch = b'\x04'
elif seektime == 0:
times =_nval(1)
times = _nval(1)
switch = b'\x03'
else:
times =_nval(int(seektime*1000000))
times = _nval(int(seektime*1000000))
switch = b'\x03'
parity = b'\x00'
header_magic= b'\xA2\x9D\xB0\xD3\x04'
header_magic = b'\xA2\x9D\xB0\xD3\x04'
sep_0 = b'\x1A'
vid = _gen_vid(video_id)
time_tag = b'\x28'
@@ -75,7 +79,8 @@ def _build(video_id, seektime, topchat_only):
sep_3 = b'\x00\x58\x03\x60'
sep_4 = b'\x68' + parity + b'\x72\x04\x08'
sep_5 = b'\x10' + parity + b'\x78\x00'
body = [
body = b''.join([
sep_0,
_nval(len(vid)),
vid,
@@ -90,18 +95,17 @@ def _build(video_id, seektime, topchat_only):
sep_4,
switch_01,
sep_5
]
body = reduce(lambda x, y: x+y, body)
])
return urllib.parse.quote(
b64enc( header_magic +
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
---------

View File

@@ -11,6 +11,8 @@ Author: taizan-hokuto (2019) @taizan205
ver 0.0.1 2019.10.05
'''
def _gen_vid(video_id):
"""generate video_id parameter.
Parameter
@@ -44,34 +46,38 @@ def _gen_vid(video_id):
b64enc(reduce(lambda x, y: x+y, item)).decode()
).encode()
def _tzparity(video_id,times):
t=0
for i,s in enumerate(video_id):
def _tzparity(video_id, times):
t = 0
for i, s in enumerate(video_id):
ss = ord(s)
if(ss % 2 == 0):
t += ss*(12-i)
else:
t ^= ss*i
return ((times^t) % 2).to_bytes(1,'big')
return ((times ^ t) % 2).to_bytes(1, 'big')
def _nval(val):
"""convert value to byte array"""
if val<0: raise ValueError
if val < 0:
raise ValueError
buf = b''
while val >> 7:
m = val & 0xFF | 0x80
buf += m.to_bytes(1,'big')
buf += m.to_bytes(1, 'big')
val >>= 7
buf += val.to_bytes(1,'big')
buf += val.to_bytes(1, 'big')
return buf
def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
#_short_type2
switch_01 = b'\x04' if topchat_only else b'\x01'
parity = _tzparity(video_id, _ts1^_ts2^_ts3^_ts4^_ts5)
header_magic= b'\xD2\x87\xCC\xC8\x03'
def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
# _short_type2
switch_01 = b'\x04' if topchat_only else b'\x01'
parity = _tzparity(video_id, _ts1 ^ _ts2 ^ _ts3 ^ _ts4 ^ _ts5)
header_magic = b'\xD2\x87\xCC\xC8\x03'
sep_0 = b'\x1A'
vid = _gen_vid(video_id)
time_tag = b'\x28'
@@ -92,14 +98,14 @@ def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
ts_3_end = b'\x58'
timestamp4 = _nval(_ts4)
sep_6 = b'\x68'
#switch
# switch
sep_7 = b'\x82\x01\x04\x08'
#switch
# switch
sep_8 = b'\x10\x00'
sep_9 = b'\x88\x01\x00\xA0\x01'
timestamp5 = _nval(_ts5)
body = [
body = b''.join([
sep_0,
_nval(len(vid)),
vid,
@@ -121,18 +127,16 @@ def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchat_only):
ts_3_end,
timestamp4,
sep_6,
switch_01,#
switch_01,
sep_7,
switch_01,#
switch_01,
sep_8,
sep_9,
timestamp5
]
body = reduce(lambda x, y: x+y, body)
])
return urllib.parse.quote(
b64enc( header_magic +
b64enc(header_magic +
_nval(len(body)) +
body
).decode()
@@ -143,15 +147,15 @@ def _times(past_sec):
n = int(time.time())
_ts1= n - random.uniform(0,1*3)
_ts2= n - random.uniform(0.01,0.99)
_ts3= n - past_sec + 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]))
_ts1 = n - random.uniform(0, 1*3)
_ts2 = n - random.uniform(0.01, 0.99)
_ts3 = n - past_sec + 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, past_sec = 0, topchat_only = False):
def getparam(video_id, past_sec=0, topchat_only=False):
'''
Parameter
---------
@@ -160,5 +164,4 @@ def getparam(video_id, past_sec = 0, topchat_only = False):
topchat_only : bool
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.paidsticker import LiveChatPaidStickerRenderer
from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
from .renderer.membership import LiveChatMembershipItemRenderer
from .. chat_processor import ChatProcessor
from ... import config
logger = config.logger(__name__)
class CompatibleProcessor(ChatProcessor):
def process(self, chat_components: list):
chatlist = []
timeout = 0
ret={}
ret = {}
ret["kind"] = "youtube#liveChatMessageListResponse"
ret["etag"] = ""
ret["nextPageToken"] = ""
@@ -24,19 +26,23 @@ class CompatibleProcessor(ChatProcessor):
timeout += chat_component.get('timeout', 0)
chatdata = chat_component.get('chatdata')
if chatdata is None: break
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
if action is None:
continue
if action.get('addChatItemAction') is None:
continue
if action['addChatItemAction'].get('item') is None:
continue
chat = self.parse(action)
if chat:
chatlist.append(chat)
ret["pollingIntervalMillis"] = int(timeout*1000)
ret["pageInfo"]={
"totalResults":len(chatlist),
"resultsPerPage":len(chatlist),
ret["pageInfo"] = {
"totalResults": len(chatlist),
"resultsPerPage": len(chatlist),
}
ret["items"] = chatlist
@@ -47,8 +53,9 @@ class CompatibleProcessor(ChatProcessor):
action = sitem.get("addChatItemAction")
if action:
item = action.get("item")
if item is None: return None
rd={}
if item is None:
return None
rd = {}
try:
renderer = self.get_renderer(item)
if renderer == None:
@@ -59,7 +66,7 @@ class CompatibleProcessor(ChatProcessor):
rd["id"] = 'LCC.' + renderer.get_id()
rd["snippet"] = renderer.get_snippet()
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"item: {sitem}")
return None
@@ -71,11 +78,12 @@ class CompatibleProcessor(ChatProcessor):
renderer = LiveChatTextMessageRenderer(item)
elif item.get("liveChatPaidMessageRenderer"):
renderer = LiveChatPaidMessageRenderer(item)
elif item.get( "liveChatPaidStickerRenderer"):
elif item.get("liveChatPaidStickerRenderer"):
renderer = LiveChatPaidStickerRenderer(item)
elif item.get("liveChatLegacyPaidMessageRenderer"):
renderer = LiveChatLegacyPaidMessageRenderer(item)
elif item.get("liveChatMembershipItemRenderer"):
renderer = LiveChatMembershipItemRenderer(item)
else:
renderer = None
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 = (renderer["headerSubtext"]["runs"][0]["text"]
)+' / '+(renderer["authorName"]["simpleText"])
return message

View File

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

View File

@@ -8,6 +8,11 @@ PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
fmt_headers = ['datetime','elapsed','authorName','message','superchat'
,'type','authorChannel']
HEADER_HTML = '''
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
'''
class HTMLArchiver(ChatProcessor):
'''
HtmlArchiver saves chat data as HTML table format.
@@ -17,6 +22,7 @@ class HTMLArchiver(ChatProcessor):
super().__init__()
self.save_path = self._checkpath(save_path)
with open(self.save_path, mode='a', encoding = 'utf-8') as f:
f.write(HEADER_HTML)
f.write('<table border="1" style="border-collapse: collapse">')
f.writelines(self._parse_html_header(fmt_headers))
self.processor = DefaultProcessor()

View File

@@ -7,12 +7,15 @@ from . worker import ExtractWorker
from . patch import Patch
from ... import config
from ... paramgen import arcparam
from ... exceptions import UnknownConnectionError
from concurrent.futures import CancelledError
from json import JSONDecodeError
from urllib.parse import quote
headers = config.headers
REPLAY_URL = "https://www.youtube.com/live_chat_replay/" \
"get_live_chat_replay?continuation="
MAX_RETRY_COUNT = 3
def _split(start, end, count, min_interval_sec = 120):
"""
@@ -57,9 +60,18 @@ def ready_blocks(video_id, duration, div, callback):
async def _create_block(session, video_id, seektime, callback):
continuation = arcparam.getparam(video_id, seektime = seektime)
url = f"{REPLAY_URL}{quote(continuation)}&pbj=1"
for _ in range(MAX_RETRY_COUNT):
try :
async with session.get(url, headers = headers) as resp:
text = await resp.text()
next_continuation, actions = parser.parse(json.loads(text))
break
except JSONDecodeError:
await asyncio.sleep(3)
else:
cancel()
raise UnknownConnectionError("Abort: Unknown connection error.")
if actions:
first = parser.get_offset(actions[0])
last = parser.get_offset(actions[-1])
@@ -71,6 +83,7 @@ def ready_blocks(video_id, duration, div, callback):
first = first,
last = last
)
"""
fetch initial blocks.
"""
@@ -95,9 +108,18 @@ def fetch_patch(callback, blocks, video_id):
async def _fetch(continuation,session) -> Patch:
url = f"{REPLAY_URL}{quote(continuation)}&pbj=1"
for _ in range(MAX_RETRY_COUNT):
try:
async with session.get(url,headers = config.headers) as resp:
chat_json = await resp.text()
continuation, actions = parser.parse(json.loads(chat_json))
break
except JSONDecodeError:
await asyncio.sleep(3)
else:
cancel()
raise UnknownConnectionError("Abort: Unknown connection error.")
if actions:
last = parser.get_offset(actions[-1])
first = parser.get_offset(actions[0])
@@ -105,6 +127,7 @@ def fetch_patch(callback, blocks, video_id):
callback(actions, last - first)
return Patch(actions, continuation, first, last)
return Patch(continuation = continuation)
"""
allocate workers and assign blocks.
"""

View File

@@ -36,7 +36,7 @@ def test_process_0():
chat_component = {
'video_id':'',
'timeout':10,
'chatdata':load_chatdata(r"tests\testdata\calculator\superchat_0.json")
'chatdata':load_chatdata(r"tests/testdata/calculator/superchat_0.json")
}
assert SuperchatCalculator().process([chat_component])=={'': 6800.0, '': 2.0}
@@ -47,7 +47,7 @@ def test_process_1():
chat_component = {
'video_id':'',
'timeout':10,
'chatdata':load_chatdata(r"tests\testdata\calculator\text_only.json")
'chatdata':load_chatdata(r"tests/testdata/calculator/text_only.json")
}
assert SuperchatCalculator().process([chat_component])=={}
@@ -59,7 +59,7 @@ def test_process_2():
chat_component = {
'video_id':'',
'timeout':10,
'chatdata':load_chatdata(r"tests\testdata\calculator\replay_end.json")
'chatdata':load_chatdata(r"tests/testdata/calculator/replay_end.json")
}
assert False
SuperchatCalculator().process([chat_component])

View File

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

File diff suppressed because it is too large Load Diff