Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6d522055 | ||
|
|
aa8a4fb592 | ||
|
|
dbde072828 | ||
|
|
92a01aa4d9 | ||
|
|
e3f9f95fb1 | ||
|
|
fa02116ab4 | ||
|
|
d8656161cd | ||
|
|
174d9f27c0 | ||
|
|
0abf8dd9f0 | ||
|
|
5ab653a1b2 | ||
|
|
6e6bb8e019 | ||
|
|
ee4b696fc5 | ||
|
|
fd1d283caa | ||
|
|
85966186b5 | ||
|
|
71341d2876 | ||
|
|
8882c82f8b | ||
|
|
cf6ed24864 | ||
|
|
584b9c5591 | ||
|
|
167c8acb93 | ||
|
|
75a31bd245 | ||
|
|
366d75c2bb | ||
|
|
b7ff2b6537 | ||
|
|
5dfd883fc9 | ||
|
|
133a8afb27 | ||
|
|
971e4bdf39 | ||
|
|
f78bfde59e | ||
|
|
a7379fd93f | ||
|
|
1cc3661d35 | ||
|
|
6c781483a9 | ||
|
|
5c3280f858 | ||
|
|
7500f79de0 | ||
|
|
94d4eebd0f | ||
|
|
2474207691 | ||
|
|
e6dbc8772e | ||
|
|
8f91e031f3 | ||
|
|
870d1f3fbe | ||
|
|
141dbcd2da | ||
|
|
6eb848f1c9 | ||
|
|
8d7fc03fe0 | ||
|
|
970e63cb38 | ||
|
|
1c817b6476 | ||
|
|
51eff10eeb | ||
|
|
18b88200a8 | ||
|
|
c95d70a232 | ||
|
|
7640586591 | ||
|
|
f7ec14e166 | ||
|
|
a4dacdb7d7 | ||
|
|
785a82b618 | ||
|
|
faf886eebd | ||
|
|
8a627414cb | ||
|
|
d14262cbcb | ||
|
|
da7c694dfb | ||
|
|
9aa35b9756 | ||
|
|
f0a1a509a0 | ||
|
|
5ebca605ac | ||
|
|
3826b32ab9 | ||
|
|
a46c82d3c0 | ||
|
|
206d052907 | ||
|
|
141d7a9299 | ||
|
|
04457eaa5c | ||
|
|
bd32c75833 | ||
|
|
84bae4ad2a | ||
|
|
d72608bf0a | ||
|
|
3243d69d7a | ||
|
|
6e1b735ebc | ||
|
|
c54481dad5 | ||
|
|
78604c84d4 | ||
|
|
21d93613a2 | ||
|
|
56bf721330 | ||
|
|
5f50598f79 | ||
|
|
5e8c438c6b | ||
|
|
23e47f6fb0 | ||
|
|
74dfe0a612 | ||
|
|
725af25d81 | ||
|
|
316fc5594a | ||
|
|
44dffc7650 | ||
|
|
102d8c48c4 | ||
|
|
f8822a053f | ||
|
|
9d624f771a | ||
|
|
778d4db28b | ||
|
|
36e0fd5c54 | ||
|
|
4252643273 | ||
|
|
c88fd8bc4e | ||
|
|
af3b6d4271 | ||
|
|
331e825c97 | ||
|
|
4019ad4b9d | ||
|
|
1074178afc | ||
|
|
55a58f532d | ||
|
|
b302454083 | ||
|
|
ff9e7de796 | ||
|
|
fe2047502a | ||
|
|
5480e3e9ed | ||
|
|
18c08f45ad | ||
|
|
a9831c6a27 | ||
|
|
60976b2584 | ||
|
|
92abf7499c | ||
|
|
4416e1a79c | ||
|
|
f7f9c1cda3 | ||
|
|
de35537be8 | ||
|
|
61d4e06470 | ||
|
|
3c95242ddf | ||
|
|
af4afb4636 | ||
|
|
05e1c908a5 | ||
|
|
e770d95fe8 | ||
|
|
eae485b914 | ||
|
|
d8c1c4491d | ||
|
|
3e941c2cf1 | ||
|
|
8b617551ad | ||
|
|
c4cf424702 | ||
|
|
6fdb3bf8cf | ||
|
|
b1292b4329 | ||
|
|
339d04ad75 | ||
|
|
abb7565e3a | ||
|
|
ee77807dbd | ||
|
|
2c598bc8f7 | ||
|
|
c7bfae9f2a | ||
|
|
eaa7bdc8b6 | ||
|
|
4a8e353098 | ||
|
|
24f08ecbdb | ||
|
|
e8510f1116 | ||
|
|
f1d8393971 | ||
|
|
04aedc82e8 | ||
|
|
228773295d | ||
|
|
59defc568c | ||
|
|
9de75788f2 | ||
|
|
76f0c0e658 | ||
|
|
0d8ecb778f | ||
|
|
a3eca8f05d | ||
|
|
bbf7a2906a | ||
|
|
1862b83eac | ||
|
|
053ff5291f | ||
|
|
4e47d4a262 | ||
|
|
436e8df4c9 | ||
|
|
5ab8cfe736 | ||
|
|
15b517e905 | ||
|
|
214a3d2be3 | ||
|
|
e968325d1f | ||
|
|
a56dc89477 | ||
|
|
38253e1d18 | ||
|
|
cc78551e90 | ||
|
|
6e37ef5d4f | ||
|
|
c126d5b825 | ||
|
|
a89503fe9e | ||
|
|
1d7678c954 | ||
|
|
dea98c33d7 | ||
|
|
5ba61db4f3 | ||
|
|
03b901d59c | ||
|
|
540f16c1a0 | ||
|
|
cc8bba8f63 | ||
|
|
22b3ec2994 | ||
|
|
9d494446e1 | ||
|
|
956c7e2640 | ||
|
|
03537c0a06 | ||
|
|
f7d1830226 | ||
|
|
76b126faf2 | ||
|
|
bbd01d6523 | ||
|
|
f8fa0e394e | ||
|
|
efdf07e3de | ||
|
|
2573cc18de | ||
|
|
1c5852421b | ||
|
|
970d111e1b | ||
|
|
1643dd1ad1 | ||
|
|
0272319fa6 | ||
|
|
fb0edef136 | ||
|
|
260a2b35a9 | ||
|
|
e03d39475e | ||
|
|
2462b8aca0 | ||
|
|
a1024c8734 | ||
|
|
6b3ca00d35 | ||
|
|
385634b709 | ||
|
|
c1a78a2743 | ||
|
|
7961801e0c | ||
|
|
5fe4e7af04 | ||
|
|
892dfb8a91 | ||
|
|
fddab22a1f | ||
|
|
7194948066 | ||
|
|
a836d92194 | ||
|
|
c408cb2713 | ||
|
|
c3d2238ead | ||
|
|
6c8d390fc7 | ||
|
|
ff1ee70d7e | ||
|
|
404623546e | ||
|
|
3f9f64d19c | ||
|
|
7996c6adad | ||
|
|
50d55da7dc | ||
|
|
c92e735715 | ||
|
|
d4a1d00e28 | ||
|
|
60c389f3f7 | ||
|
|
705bfe0bed | ||
|
|
0f7a0218b6 | ||
|
|
89d2f8978f | ||
|
|
6befc2de95 | ||
|
|
a0c5ea035a | ||
|
|
3b27c81166 | ||
|
|
26fefddddf | ||
|
|
5d86fb4b71 | ||
|
|
b5e302cdf3 | ||
|
|
5d228589f1 | ||
|
|
fd8ecec0c5 | ||
|
|
a1e48b56e6 | ||
|
|
9c41536533 | ||
|
|
2c684d04b5 | ||
|
|
30708470f2 | ||
|
|
d742a9fdf3 | ||
|
|
2fdd834caf | ||
|
|
4c558491a3 | ||
|
|
0fc9d14780 | ||
|
|
18400724b1 | ||
|
|
7b7323abf8 | ||
|
|
fc5979c025 | ||
|
|
f4dc5e9d4a | ||
|
|
347707a514 | ||
|
|
7766a39c9c | ||
|
|
48b6f2c24e | ||
|
|
907f8aba0b | ||
|
|
2616e4c4b5 | ||
|
|
d6ea673f98 | ||
|
|
2bb481a228 | ||
|
|
7308a87a61 | ||
|
|
9751289eca | ||
|
|
044fe97aa5 | ||
|
|
28e3289790 | ||
|
|
585a4be7dc | ||
|
|
b84a82341e | ||
|
|
b4f3307b1c | ||
|
|
be7ac97c62 | ||
|
|
f8de4e7e39 | ||
|
|
f8bcc8a453 |
@@ -1,3 +1,5 @@
|
|||||||
include requirements.txt
|
include requirements.txt
|
||||||
include requirements_test.txt
|
include requirements_test.txt
|
||||||
|
include README.md
|
||||||
|
global-exclude tests/*
|
||||||
|
global-exclude pytchat/testrun*.py
|
||||||
168
README.md
168
README.md
@@ -7,32 +7,52 @@ pytchat is a python library for fetching youtube live chat.
|
|||||||
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.
|
without using youtube api, Selenium or BeautifulSoup.
|
||||||
|
|
||||||
|
pytchatは、YouTubeチャットを閲覧するためのpythonライブラリです。
|
||||||
|
|
||||||
Other features:
|
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.
|
+ Available on asyncio context.
|
||||||
+ Quick fetching of initial chat data by generating continuation params
|
+ Quick fetching of initial chat data by generating continuation params
|
||||||
instead of web scraping.
|
instead of web scraping.
|
||||||
|
|
||||||
For more detailed information, see [wiki](https://github.com/taizan-hokuto/pytchat/wiki).
|
For more detailed information, see [wiki](https://github.com/taizan-hokuto/pytchat/wiki). <br>
|
||||||
|
より詳細な解説は[wiki](https://github.com/taizan-hokuto/pytchat/wiki/Home_jp)を参照してください。
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
```python
|
```python
|
||||||
pip install pytchat
|
pip install pytchat
|
||||||
```
|
```
|
||||||
## Demo
|
|
||||||

|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
One-liner command.
|
||||||
|
Save chat data to html, with embedded custom emojis.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pytchat -v https://www.youtube.com/watch?v=ZJ6Q4U_Vg6s -o "c:/temp/"
|
||||||
|
# options:
|
||||||
|
# -v : Video ID or URL that includes ID
|
||||||
|
# -o : output directory (default path: './')
|
||||||
|
# saved filename is [video_id].html
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### on-demand mode
|
### on-demand mode
|
||||||
```python
|
```python
|
||||||
from pytchat import LiveChat
|
from pytchat import LiveChat
|
||||||
|
livechat = LiveChat(video_id = "Zvp1pJpie4I")
|
||||||
chat = LiveChat("rsHWP7IjMiw")
|
# It is also possible to specify a URL that includes the video ID:
|
||||||
while chat.is_alive():
|
# livechat = LiveChat("https://www.youtube.com/watch?v=Zvp1pJpie4I")
|
||||||
data = chat.get()
|
while livechat.is_alive():
|
||||||
for c in data.items:
|
try:
|
||||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
chatdata = livechat.get()
|
||||||
data.tick()
|
for c in chatdata.items:
|
||||||
|
print(f"{c.datetime} [{c.author.name}]- {c.message}")
|
||||||
|
chatdata.tick()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
livechat.terminate()
|
||||||
|
break
|
||||||
```
|
```
|
||||||
|
|
||||||
### callback mode
|
### callback mode
|
||||||
@@ -40,41 +60,48 @@ while chat.is_alive():
|
|||||||
from pytchat import LiveChat
|
from pytchat import LiveChat
|
||||||
import time
|
import time
|
||||||
|
|
||||||
#callback function is automatically called.
|
def main():
|
||||||
def display(data):
|
livechat = LiveChat(video_id = "Zvp1pJpie4I", callback = disp)
|
||||||
for c in data.items:
|
while livechat.is_alive():
|
||||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
#other background operation.
|
||||||
data.tick()
|
time.sleep(1)
|
||||||
|
livechat.terminate()
|
||||||
|
|
||||||
|
#callback function (automatically called)
|
||||||
|
def disp(chatdata):
|
||||||
|
for c in chatdata.items:
|
||||||
|
print(f"{c.datetime} [{c.author.name}]- {c.message}")
|
||||||
|
chatdata.tick()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
||||||
#entry point
|
|
||||||
chat = LiveChat("rsHWP7IjMiw", callback = display)
|
|
||||||
while chat.is_alive():
|
|
||||||
time.sleep(3)
|
|
||||||
#other background operation.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### asyncio context:
|
### asyncio context:
|
||||||
```python
|
```python
|
||||||
from pytchat import LiveChatAsync
|
from pytchat import LiveChatAsync
|
||||||
|
from concurrent.futures import CancelledError
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
chat = LiveChatAsync("rsHWP7IjMiw", callback = func)
|
livechat = LiveChatAsync("Zvp1pJpie4I", callback = func)
|
||||||
while chat.is_alive():
|
while livechat.is_alive():
|
||||||
await asyncio.sleep(3)
|
|
||||||
#other background operation.
|
#other background operation.
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
#callback function is automatically called.
|
#callback function is automatically called.
|
||||||
async def func(data):
|
async def func(chatdata):
|
||||||
for c in data.items:
|
for c in chatdata.items:
|
||||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||||
await data.tick_async()
|
await chatdata.tick_async()
|
||||||
|
|
||||||
try:
|
if __name__ == '__main__':
|
||||||
loop = asyncio.get_event_loop()
|
try:
|
||||||
loop.run_until_complete(main())
|
loop = asyncio.get_event_loop()
|
||||||
except CancelledError:
|
loop.run_until_complete(main())
|
||||||
pass
|
except CancelledError:
|
||||||
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@@ -83,37 +110,61 @@ except CancelledError:
|
|||||||
from pytchat import LiveChat, CompatibleProcessor
|
from pytchat import LiveChat, CompatibleProcessor
|
||||||
import time
|
import time
|
||||||
|
|
||||||
chat = LiveChat("rsHWP7IjMiw",
|
chat = LiveChat("Zvp1pJpie4I",
|
||||||
processor = CompatibleProcessor() )
|
processor = CompatibleProcessor() )
|
||||||
|
|
||||||
while chat.is_alive():
|
while chat.is_alive():
|
||||||
data = chat.get()
|
try:
|
||||||
polling = data['pollingIntervalMillis']/1000
|
data = chat.get()
|
||||||
for c in data['items']:
|
polling = data['pollingIntervalMillis']/1000
|
||||||
if c.get('snippet'):
|
for c in data['items']:
|
||||||
print(f"[{c['authorDetails']['displayName']}]"
|
if c.get('snippet'):
|
||||||
f"-{c['snippet']['displayMessage']}")
|
print(f"[{c['authorDetails']['displayName']}]"
|
||||||
time.sleep(polling/len(data['items']))
|
f"-{c['snippet']['displayMessage']}")
|
||||||
|
time.sleep(polling/len(data['items']))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
chat.terminate()
|
||||||
```
|
```
|
||||||
### replay:
|
### replay:
|
||||||
|
If specified video is not live,
|
||||||
|
automatically try to fetch archived chat data.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pytchat import ReplayChat
|
from pytchat import LiveChat
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
#seektime (seconds): start position of chat.
|
#seektime (seconds): start position of chat.
|
||||||
chat = ReplayChat("ojes5ULOqhc", seektime = 60*30)
|
chat = LiveChat("ojes5ULOqhc", seektime = 60*30)
|
||||||
while chat.is_alive():
|
print('Replay from 30:00')
|
||||||
data = chat.get()
|
try:
|
||||||
for c in data.items:
|
while chat.is_alive():
|
||||||
print(f"{c.elapsedTime} [{c.author.name}]-{c.message} {c.amountString}")
|
data = chat.get()
|
||||||
data.tick()
|
for c in data.items:
|
||||||
|
print(f"{c.elapsedTime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||||
|
data.tick()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
chat.terminate()
|
||||||
|
|
||||||
main()
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
### Extract archived chat data as [HTML](https://github.com/taizan-hokuto/pytchat/wiki/HTMLArchiver) or [tab separated values](https://github.com/taizan-hokuto/pytchat/wiki/TSVArchiver).
|
||||||
|
```python
|
||||||
|
from pytchat import HTMLArchiver, Extractor
|
||||||
|
|
||||||
|
video_id = "*******"
|
||||||
|
ex = Extractor(
|
||||||
|
video_id,
|
||||||
|
div=10,
|
||||||
|
processor=HTMLArchiver("c:/test.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
ex.extract()
|
||||||
|
print("finished.")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure of Default Processor
|
## Structure of Default Processor
|
||||||
Each item can be got with items() function.
|
Each item can be got with `items` function.
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>name</th>
|
<th>name</th>
|
||||||
@@ -138,7 +189,7 @@ Each item can be got with items() function.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>messageEx</td>
|
<td>messageEx</td>
|
||||||
<td>str</td>
|
<td>str</td>
|
||||||
<td>list of message texts and emoji URLs.</td>
|
<td>list of message texts and emoji dicts(id, txt, url).</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>timestamp</td>
|
<td>timestamp</td>
|
||||||
@@ -239,8 +290,17 @@ Structure of author object.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
|
|
||||||
|
## Contributes
|
||||||
|
Great thanks:
|
||||||
|
|
||||||
|
Most of source code of CLI refer to:
|
||||||
|
|
||||||
|
[PetterKraabol / Twitch-Chat-Downloader](https://github.com/PetterKraabol/Twitch-Chat-Downloader)
|
||||||
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
[taizan-hokuto](https://github.com/taizan-hokuto)
|
[taizan-hokuto](https://github.com/taizan-hokuto)
|
||||||
|
|
||||||
[twitter:@taizan205](https://twitter.com/taizan205)
|
[twitter:@taizan205](https://twitter.com/taizan205)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
pytchat is a python library for fetching youtube live chat without using yt api, Selenium, or BeautifulSoup.
|
pytchat is a lightweight python library to browse youtube livechat without Selenium or BeautifulSoup.
|
||||||
"""
|
"""
|
||||||
__copyright__ = 'Copyright (C) 2019 taizan-hokuto'
|
__copyright__ = 'Copyright (C) 2019 taizan-hokuto'
|
||||||
__version__ = '0.0.4.1'
|
__version__ = '0.1.0'
|
||||||
__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'
|
||||||
@@ -11,16 +11,22 @@ __url__ = 'https://github.com/taizan-hokuto/pytchat'
|
|||||||
__all__ = ["core_async","core_multithread","processors"]
|
__all__ = ["core_async","core_multithread","processors"]
|
||||||
|
|
||||||
from .api import (
|
from .api import (
|
||||||
|
cli,
|
||||||
config,
|
config,
|
||||||
LiveChat,
|
LiveChat,
|
||||||
LiveChatAsync,
|
LiveChatAsync,
|
||||||
ReplayChat,
|
|
||||||
ReplayChatAsync,
|
|
||||||
ChatProcessor,
|
ChatProcessor,
|
||||||
CompatibleProcessor,
|
CompatibleProcessor,
|
||||||
DefaultProcessor,
|
DummyProcessor,
|
||||||
|
DefaultProcessor,
|
||||||
|
Extractor,
|
||||||
|
HTMLArchiver,
|
||||||
|
TSVArchiver,
|
||||||
|
JsonfileArchiver,
|
||||||
SimpleDisplayProcessor,
|
SimpleDisplayProcessor,
|
||||||
JsonfileArchiveProcessor,
|
|
||||||
SpeedCalculator,
|
SpeedCalculator,
|
||||||
DummyProcessor
|
SuperchatCalculator,
|
||||||
)
|
VideoInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
# flake8: noqa
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
from . import cli
|
||||||
|
from . import config
|
||||||
from .core_multithread.livechat import LiveChat
|
from .core_multithread.livechat import LiveChat
|
||||||
from .core_async.livechat import LiveChatAsync
|
from .core_async.livechat import LiveChatAsync
|
||||||
from .core_multithread.replaychat import ReplayChat
|
|
||||||
from .core_async.replaychat import ReplayChatAsync
|
|
||||||
from .processors.chat_processor import ChatProcessor
|
from .processors.chat_processor import ChatProcessor
|
||||||
from .processors.default.processor import DefaultProcessor
|
|
||||||
from .processors.compatible.processor import CompatibleProcessor
|
from .processors.compatible.processor import CompatibleProcessor
|
||||||
from .processors.simple_display_processor import SimpleDisplayProcessor
|
from .processors.default.processor import DefaultProcessor
|
||||||
from .processors.jsonfile_archive_processor import JsonfileArchiveProcessor
|
|
||||||
from .processors.speed_calculator import SpeedCalculator
|
|
||||||
from .processors.dummy_processor import DummyProcessor
|
from .processors.dummy_processor import DummyProcessor
|
||||||
from . import config
|
from .processors.html_archiver import HTMLArchiver
|
||||||
|
from .processors.tsv_archiver import TSVArchiver
|
||||||
|
from .processors.jsonfile_archiver import JsonfileArchiver
|
||||||
|
from .processors.simple_display_processor import SimpleDisplayProcessor
|
||||||
|
from .processors.speed.calculator import SpeedCalculator
|
||||||
|
from .processors.superchat.calculator import SuperchatCalculator
|
||||||
|
from .tool.extract.extractor import Extractor
|
||||||
|
from .tool.videoinfo import VideoInfo
|
||||||
|
|
||||||
|
# flake8: noqa
|
||||||
66
pytchat/cli/__init__.py
Normal file
66
pytchat/cli/__init__.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from pytchat.util.extract_video_id import extract_video_id
|
||||||
|
from .arguments import Arguments
|
||||||
|
from .. exceptions import InvalidVideoIdException, NoContents
|
||||||
|
from .. processors.html_archiver import HTMLArchiver
|
||||||
|
from .. tool.extract.extractor import Extractor
|
||||||
|
from .. tool.videoinfo import VideoInfo
|
||||||
|
from .. import __version__
|
||||||
|
|
||||||
|
'''
|
||||||
|
Most of CLI modules refer to
|
||||||
|
Petter Kraabøl's Twitch-Chat-Downloader
|
||||||
|
https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
||||||
|
(MIT License)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Arguments
|
||||||
|
parser = argparse.ArgumentParser(description=f'pytchat v{__version__}')
|
||||||
|
# parser.add_argument('VideoID_or_URL', type=str, default='__NONE__',nargs='?',
|
||||||
|
# help='Video ID, or URL that includes id.\n'
|
||||||
|
# 'If ID starts with a hyphen (-), enclose the ID in square brackets.')
|
||||||
|
parser.add_argument('-v', f'--{Arguments.Name.VIDEO_IDS}', type=str,
|
||||||
|
help='Video ID (or URL that includes Video ID). You can specify multiple video IDs by separating them with commas without spaces.\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="./"', default='./')
|
||||||
|
parser.add_argument(f'--{Arguments.Name.VERSION}', action='store_true',
|
||||||
|
help='Show version')
|
||||||
|
Arguments(parser.parse_args().__dict__)
|
||||||
|
if Arguments().print_version:
|
||||||
|
print(f'pytchat v{__version__} © 2019 taizan-hokuto')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extractor
|
||||||
|
if Arguments().video_ids:
|
||||||
|
for video_id in Arguments().video_ids:
|
||||||
|
if '[' in video_id:
|
||||||
|
video_id = video_id.replace('[', '').replace(']', '')
|
||||||
|
try:
|
||||||
|
info = VideoInfo(video_id)
|
||||||
|
print(f"Extracting...\n"
|
||||||
|
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'),
|
||||||
|
callback=_disp_progress
|
||||||
|
).extract()
|
||||||
|
print("\nExtraction end.\n")
|
||||||
|
except InvalidVideoIdException:
|
||||||
|
print("Invalid Video ID or URL:", video_id)
|
||||||
|
except (TypeError, NoContents) as e:
|
||||||
|
print(e)
|
||||||
|
return
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
def _disp_progress(a, b):
|
||||||
|
print('.', end="", flush=True)
|
||||||
43
pytchat/cli/arguments.py
Normal file
43
pytchat/cli/arguments.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import Optional, Dict, Union, List
|
||||||
|
from .singleton import Singleton
|
||||||
|
|
||||||
|
'''
|
||||||
|
This modules refer to
|
||||||
|
Petter Kraabøl's Twitch-Chat-Downloader
|
||||||
|
https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
||||||
|
(MIT License)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class Arguments(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
Arguments singleton
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Name:
|
||||||
|
VERSION: str = 'version'
|
||||||
|
OUTPUT: str = 'output_dir'
|
||||||
|
VIDEO_IDS: str = 'video_id'
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
arguments: Optional[Dict[str, Union[str, bool, int]]] = None):
|
||||||
|
"""
|
||||||
|
Initialize arguments
|
||||||
|
:param arguments: Arguments from cli
|
||||||
|
(Optional to call singleton instance without parameters)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if arguments is None:
|
||||||
|
print('Error: arguments were not provided')
|
||||||
|
exit()
|
||||||
|
|
||||||
|
self.print_version: bool = arguments[Arguments.Name.VERSION]
|
||||||
|
self.output: str = arguments[Arguments.Name.OUTPUT]
|
||||||
|
self.video_ids: List[int] = []
|
||||||
|
# Videos
|
||||||
|
if arguments[Arguments.Name.VIDEO_IDS]:
|
||||||
|
self.video_ids = [video_id
|
||||||
|
for video_id in arguments[Arguments.Name.VIDEO_IDS].split(',')]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
21
pytchat/cli/singleton.py
Normal file
21
pytchat/cli/singleton.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'''
|
||||||
|
This modules refer to
|
||||||
|
Petter Kraabøl's Twitch-Chat-Downloader
|
||||||
|
https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
||||||
|
(MIT License)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class Singleton(type):
|
||||||
|
"""
|
||||||
|
Abstract class for singletons
|
||||||
|
"""
|
||||||
|
_instances = {}
|
||||||
|
|
||||||
|
def __call__(cls, *args, **kwargs):
|
||||||
|
if cls not in cls._instances:
|
||||||
|
cls._instances[cls] = super().__call__(*args, **kwargs)
|
||||||
|
return cls._instances[cls]
|
||||||
|
|
||||||
|
def get_instance(cls, *args, **kwargs):
|
||||||
|
cls.__call__(*args, **kwargs)
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from . import mylogger
|
from . import mylogger
|
||||||
|
|
||||||
LOGGER_MODE = None
|
|
||||||
|
|
||||||
headers = {
|
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'}
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36'}
|
||||||
|
|
||||||
def logger(module_name: str):
|
|
||||||
module_logger = mylogger.get_logger(module_name, mode = LOGGER_MODE)
|
def logger(module_name: str, loglevel=None):
|
||||||
|
module_logger = mylogger.get_logger(module_name, loglevel=loglevel)
|
||||||
return module_logger
|
return module_logger
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
from logging import NullHandler, getLogger, StreamHandler, FileHandler, Formatter
|
from logging import NullHandler, getLogger, StreamHandler, FileHandler
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
def get_logger(modname,mode=logging.DEBUG):
|
def get_logger(modname, loglevel=logging.DEBUG):
|
||||||
logger = getLogger(modname)
|
logger = getLogger(modname)
|
||||||
if mode == None:
|
if loglevel is None:
|
||||||
logger.addHandler(NullHandler())
|
logger.addHandler(NullHandler())
|
||||||
return logger
|
return logger
|
||||||
logger.setLevel(mode)
|
logger.setLevel(loglevel)
|
||||||
#create handler1 for showing info
|
# create handler1 for showing info
|
||||||
handler1 = StreamHandler()
|
handler1 = StreamHandler()
|
||||||
my_formatter = MyFormatter()
|
my_formatter = MyFormatter()
|
||||||
handler1.setFormatter(my_formatter)
|
handler1.setFormatter(my_formatter)
|
||||||
|
|
||||||
handler1.setLevel(mode)
|
handler1.setLevel(loglevel)
|
||||||
logger.addHandler(handler1)
|
logger.addHandler(handler1)
|
||||||
#create handler2 for recording log file
|
# create handler2 for recording log file
|
||||||
if mode <= logging.DEBUG:
|
if loglevel <= logging.DEBUG:
|
||||||
handler2 = FileHandler(filename="log.txt", encoding='utf-8')
|
handler2 = FileHandler(filename="log.txt", encoding='utf-8')
|
||||||
handler2.setLevel(logging.ERROR)
|
handler2.setLevel(logging.ERROR)
|
||||||
handler2.setFormatter(my_formatter)
|
handler2.setFormatter(my_formatter)
|
||||||
|
|
||||||
|
|
||||||
logger.addHandler(handler2)
|
logger.addHandler(handler2)
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
class MyFormatter(logging.Formatter):
|
class MyFormatter(logging.Formatter):
|
||||||
def format(self, record):
|
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()
|
timestamp = (
|
||||||
return s
|
datetime.fromtimestamp(record.created)).strftime("%m-%d %H:%M:%S")
|
||||||
|
module = (record.module).ljust(15)
|
||||||
|
funcname = (record.funcName).ljust(18)
|
||||||
|
lineno = str(record.lineno).rjust(4)
|
||||||
|
message = record.getMessage()
|
||||||
|
|
||||||
|
return timestamp + '| ' + module + ' { ' + funcname + ':' + lineno + '} - ' + message
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
class Buffer(asyncio.Queue):
|
class Buffer(asyncio.Queue):
|
||||||
'''
|
'''
|
||||||
チャットデータを格納するバッファの役割を持つFIFOキュー
|
チャットデータを格納するバッファの役割を持つFIFOキュー
|
||||||
@@ -10,19 +12,27 @@ class Buffer(asyncio.Queue):
|
|||||||
格納するチャットブロックの最大個数。0の場合は無限。
|
格納するチャットブロックの最大個数。0の場合は無限。
|
||||||
最大値を超える場合は古いチャットブロックから破棄される。
|
最大値を超える場合は古いチャットブロックから破棄される。
|
||||||
'''
|
'''
|
||||||
def __init__(self,maxsize = 0):
|
|
||||||
|
def __init__(self, maxsize=0):
|
||||||
super().__init__(maxsize)
|
super().__init__(maxsize)
|
||||||
|
|
||||||
async def put(self,item):
|
async def put(self, item):
|
||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
if super().full():
|
if super().full():
|
||||||
super().get_nowait()
|
super().get_nowait()
|
||||||
await super().put(item)
|
await super().put(item)
|
||||||
|
|
||||||
|
def put_nowait(self, item):
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
if super().full():
|
||||||
|
super().get_nowait()
|
||||||
|
super().put_nowait(item)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
ret = []
|
ret = []
|
||||||
ret.append(await super().get())
|
ret.append(await super().get())
|
||||||
while not super().empty():
|
while not super().empty():
|
||||||
ret.append(super().get_nowait())
|
ret.append(super().get_nowait())
|
||||||
return ret
|
return ret
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import aiohttp, asyncio
|
import aiohttp
|
||||||
import datetime
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from aiohttp.client_exceptions import ClientConnectorError
|
from aiohttp.client_exceptions import ClientConnectorError
|
||||||
from concurrent.futures import CancelledError
|
from concurrent.futures import CancelledError
|
||||||
|
from asyncio import Queue
|
||||||
from .buffer import Buffer
|
from .buffer import Buffer
|
||||||
from ..parser.live import Parser
|
from ..parser.live import Parser
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
from .. import exceptions
|
||||||
from ..paramgen import liveparam
|
from ..paramgen import liveparam, arcparam
|
||||||
from ..processors.default.processor import DefaultProcessor
|
from ..processors.default.processor import DefaultProcessor
|
||||||
from ..processors.combinator import Combinator
|
from ..processors.combinator import Combinator
|
||||||
|
from ..util.extract_video_id import extract_video_id
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
headers = config.headers
|
headers = config.headers
|
||||||
MAX_RETRY = 10
|
MAX_RETRY = 10
|
||||||
|
|
||||||
@@ -29,6 +29,11 @@ class LiveChatAsync:
|
|||||||
video_id : str
|
video_id : str
|
||||||
動画ID
|
動画ID
|
||||||
|
|
||||||
|
seektime : int
|
||||||
|
(ライブチャット取得時は無視)
|
||||||
|
取得開始するアーカイブ済みチャットの経過時間(秒)
|
||||||
|
マイナス値を指定した場合は、配信開始前のチャットも取得する。
|
||||||
|
|
||||||
processor : ChatProcessor
|
processor : ChatProcessor
|
||||||
チャットデータを加工するオブジェクト
|
チャットデータを加工するオブジェクト
|
||||||
|
|
||||||
@@ -52,8 +57,15 @@ class LiveChatAsync:
|
|||||||
direct_mode : bool
|
direct_mode : bool
|
||||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||||
Trueの場合、callbackの設定が必須
|
Trueの場合、callbackの設定が必須
|
||||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||||
|
|
||||||
|
force_replay : bool
|
||||||
|
Trueの場合、ライブチャットが取得できる場合であっても
|
||||||
|
強制的にアーカイブ済みチャットを取得する。
|
||||||
|
|
||||||
|
topchat_only : bool
|
||||||
|
Trueの場合、上位チャットのみ取得する。
|
||||||
|
|
||||||
Attributes
|
Attributes
|
||||||
---------
|
---------
|
||||||
_is_alive : bool
|
_is_alive : bool
|
||||||
@@ -63,14 +75,20 @@ class LiveChatAsync:
|
|||||||
_setup_finished = False
|
_setup_finished = False
|
||||||
|
|
||||||
def __init__(self, video_id,
|
def __init__(self, video_id,
|
||||||
processor = DefaultProcessor(),
|
seektime=-1,
|
||||||
buffer = None,
|
processor=DefaultProcessor(),
|
||||||
interruptable = True,
|
buffer=None,
|
||||||
callback = None,
|
interruptable=True,
|
||||||
done_callback = None,
|
callback=None,
|
||||||
exception_handler = None,
|
done_callback=None,
|
||||||
direct_mode = False):
|
exception_handler=None,
|
||||||
self.video_id = video_id
|
direct_mode=False,
|
||||||
|
force_replay=False,
|
||||||
|
topchat_only=False,
|
||||||
|
logger=config.logger(__name__),
|
||||||
|
):
|
||||||
|
self._video_id = extract_video_id(video_id)
|
||||||
|
self.seektime = seektime
|
||||||
if isinstance(processor, tuple):
|
if isinstance(processor, tuple):
|
||||||
self.processor = Combinator(processor)
|
self.processor = Combinator(processor)
|
||||||
else:
|
else:
|
||||||
@@ -80,149 +98,175 @@ class LiveChatAsync:
|
|||||||
self._done_callback = done_callback
|
self._done_callback = done_callback
|
||||||
self._exception_handler = exception_handler
|
self._exception_handler = exception_handler
|
||||||
self._direct_mode = direct_mode
|
self._direct_mode = direct_mode
|
||||||
self._is_alive = True
|
self._is_alive = True
|
||||||
self._parser = Parser()
|
self._is_replay = force_replay
|
||||||
|
self._parser = Parser(is_replay=self._is_replay)
|
||||||
|
self._pauser = Queue()
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
self._first_fetch = True
|
||||||
|
self._fetch_url = "live_chat/get_live_chat?continuation="
|
||||||
|
self._topchat_only = topchat_only
|
||||||
|
self._logger = logger
|
||||||
|
self.exception = None
|
||||||
|
LiveChatAsync._logger = logger
|
||||||
|
|
||||||
|
if exception_handler:
|
||||||
|
self._set_exception_handler(exception_handler)
|
||||||
|
if interruptable:
|
||||||
|
signal.signal(signal.SIGINT,
|
||||||
|
(lambda a, b: asyncio.create_task(
|
||||||
|
LiveChatAsync.shutdown(None, signal.SIGINT, b))))
|
||||||
self._setup()
|
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):
|
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 exceptions.IllegalFunctionCall(
|
||||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
"When direct_mode=True, callback parameter is required.")
|
||||||
else:
|
else:
|
||||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
# direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||||
if self._buffer is None:
|
if self._buffer is None:
|
||||||
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を呼ぶループタスクの開始
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.create_task(self._callback_loop(self._callback))
|
loop.create_task(self._callback_loop(self._callback))
|
||||||
#_listenループタスクの開始
|
# _listenループタスクの開始
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
listen_task = loop.create_task(self._startlisten())
|
self.listen_task = loop.create_task(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)
|
self.listen_task.add_done_callback(self._finish)
|
||||||
else:
|
else:
|
||||||
listen_task.add_done_callback(self._done_callback)
|
self.listen_task.add_done_callback(self._done_callback)
|
||||||
|
|
||||||
async def _startlisten(self):
|
async def _startlisten(self):
|
||||||
"""最初のcontinuationパラメータを取得し、
|
"""Fetch first continuation parameter,
|
||||||
_listenループのタスクを作成し開始する
|
create and start _listen loop.
|
||||||
"""
|
"""
|
||||||
initial_continuation = await self._get_initial_continuation()
|
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||||
if initial_continuation is None:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
|
||||||
return
|
|
||||||
await self._listen(initial_continuation)
|
await self._listen(initial_continuation)
|
||||||
|
|
||||||
async def _get_initial_continuation(self):
|
|
||||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
|
||||||
try:
|
|
||||||
initial_continuation = liveparam.getparam(self.video_id)
|
|
||||||
except ChatParseException as e:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
|
||||||
return
|
|
||||||
except KeyError:
|
|
||||||
logger.debug(f"[{self.video_id}]KeyError:"
|
|
||||||
f"{traceback.format_exc(limit = -1)}")
|
|
||||||
self.terminate()
|
|
||||||
return
|
|
||||||
return initial_continuation
|
|
||||||
|
|
||||||
async def _listen(self, continuation):
|
async def _listen(self, continuation):
|
||||||
''' continuationに紐付いたチャットデータを取得し
|
''' Fetch chat data and store them into buffer,
|
||||||
Bufferにチャットデータを格納、
|
get next continuaiton parameter and loop.
|
||||||
次のcontinuaitonを取得してループする。
|
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
continuation : str
|
continuation : str
|
||||||
次のチャットデータ取得に必要なパラメータ
|
parameter for next chat data
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
while(continuation and self._is_alive):
|
while(continuation and self._is_alive):
|
||||||
livechat_json = (await
|
continuation = await self._check_pause(continuation)
|
||||||
self._get_livechat_json(continuation, session, headers)
|
contents = await self._get_contents(
|
||||||
)
|
continuation, session, headers)
|
||||||
metadata, chatdata = self._parser.parse( livechat_json )
|
metadata, chatdata = self._parser.parse(contents)
|
||||||
timeout = metadata['timeoutMs']/1000
|
|
||||||
|
timeout = metadata['timeoutMs'] / 1000
|
||||||
chat_component = {
|
chat_component = {
|
||||||
"video_id" : self.video_id,
|
"video_id": self._video_id,
|
||||||
"timeout" : timeout,
|
"timeout": timeout,
|
||||||
"chatdata" : chatdata
|
"chatdata": chatdata
|
||||||
}
|
}
|
||||||
time_mark =time.time()
|
time_mark = time.time()
|
||||||
if self._direct_mode:
|
if self._direct_mode:
|
||||||
await self._callback(
|
processed_chat = self.processor.process(
|
||||||
self.processor.process([chat_component])
|
[chat_component])
|
||||||
)
|
if isinstance(processed_chat, tuple):
|
||||||
|
await self._callback(*processed_chat)
|
||||||
|
else:
|
||||||
|
await self._callback(processed_chat)
|
||||||
else:
|
else:
|
||||||
await self._buffer.put(chat_component)
|
await self._buffer.put(chat_component)
|
||||||
diff_time = timeout - (time.time()-time_mark)
|
diff_time = timeout - (time.time() - time_mark)
|
||||||
await asyncio.sleep(diff_time)
|
await asyncio.sleep(diff_time)
|
||||||
continuation = metadata.get('continuation')
|
continuation = metadata.get('continuation')
|
||||||
except ChatParseException as e:
|
except exceptions.ChatParseException as e:
|
||||||
self.terminate()
|
self._logger.debug(f"[{self._video_id}]{str(e)}")
|
||||||
logger.error(f"{str(e)}(video_id:\"{self.video_id}\")")
|
raise
|
||||||
return
|
except (TypeError, json.JSONDecodeError):
|
||||||
except (TypeError , json.JSONDecodeError) :
|
self._logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||||
self.terminate()
|
raise
|
||||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
|
||||||
return
|
self._logger.debug(f"[{self._video_id}]finished fetching chat.")
|
||||||
|
raise exceptions.ChatDataFinished
|
||||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
|
||||||
|
async def _check_pause(self, continuation):
|
||||||
|
if self._pauser.empty():
|
||||||
|
'''pause'''
|
||||||
|
await self._pauser.get()
|
||||||
|
'''resume:
|
||||||
|
prohibit from blocking by putting None into _pauser.
|
||||||
|
'''
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
if not self._is_replay:
|
||||||
|
continuation = liveparam.getparam(
|
||||||
|
self._video_id, 3, self._topchat_only)
|
||||||
|
return continuation
|
||||||
|
|
||||||
|
async def _get_contents(self, continuation, session, headers):
|
||||||
|
'''Get 'continuationContents' from livechat json.
|
||||||
|
If contents is None at first fetching,
|
||||||
|
try to fetch archive chat data.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
-------
|
||||||
|
'continuationContents' which includes metadata & chatdata.
|
||||||
|
'''
|
||||||
|
livechat_json = await self._get_livechat_json(continuation, session, headers)
|
||||||
|
contents = self._parser.get_contents(livechat_json)
|
||||||
|
if self._first_fetch:
|
||||||
|
if contents is None or self._is_replay:
|
||||||
|
'''Try to fetch archive chat data.'''
|
||||||
|
self._parser.is_replay = True
|
||||||
|
self._fetch_url = "live_chat_replay/get_live_chat_replay?continuation="
|
||||||
|
continuation = arcparam.getparam(
|
||||||
|
self._video_id, self.seektime, self._topchat_only)
|
||||||
|
livechat_json = (await self._get_livechat_json(
|
||||||
|
continuation, session, headers))
|
||||||
|
reload_continuation = self._parser.reload_continuation(
|
||||||
|
self._parser.get_contents(livechat_json))
|
||||||
|
if reload_continuation:
|
||||||
|
livechat_json = (await self._get_livechat_json(
|
||||||
|
reload_continuation, session, headers))
|
||||||
|
contents = self._parser.get_contents(livechat_json)
|
||||||
|
self._is_replay = True
|
||||||
|
self._first_fetch = False
|
||||||
|
return contents
|
||||||
|
|
||||||
async def _get_livechat_json(self, continuation, session, headers):
|
async def _get_livechat_json(self, continuation, session, headers):
|
||||||
'''
|
'''
|
||||||
チャットデータが格納されたjsonデータを取得する。
|
Get json which includes chat data.
|
||||||
'''
|
'''
|
||||||
continuation = urllib.parse.quote(continuation)
|
continuation = urllib.parse.quote(continuation)
|
||||||
livechat_json = None
|
livechat_json = None
|
||||||
status_code = 0
|
status_code = 0
|
||||||
url =(
|
url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||||
f"https://www.youtube.com/live_chat/get_live_chat?"
|
|
||||||
f"continuation={continuation}&pbj=1")
|
|
||||||
for _ in range(MAX_RETRY + 1):
|
for _ in range(MAX_RETRY + 1):
|
||||||
async with session.get(url ,headers = headers) as resp:
|
async with session.get(url, headers=headers) as resp:
|
||||||
try:
|
try:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
status_code = resp.status
|
|
||||||
livechat_json = json.loads(text)
|
livechat_json = json.loads(text)
|
||||||
break
|
break
|
||||||
except (ClientConnectorError,json.JSONDecodeError) :
|
except (ClientConnectorError, json.JSONDecodeError):
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
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}")
|
||||||
self.terminate()
|
|
||||||
return None
|
return None
|
||||||
return livechat_json
|
return livechat_json
|
||||||
|
|
||||||
async def _callback_loop(self,callback):
|
async def _callback_loop(self, callback):
|
||||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
callback : func
|
callback : func
|
||||||
@@ -230,64 +274,91 @@ class LiveChatAsync:
|
|||||||
"""
|
"""
|
||||||
while self.is_alive():
|
while self.is_alive():
|
||||||
items = await self._buffer.get()
|
items = await self._buffer.get()
|
||||||
data = self.processor.process(items)
|
processed_chat = self.processor.process(items)
|
||||||
await callback(data)
|
if isinstance(processed_chat, tuple):
|
||||||
|
await self._callback(*processed_chat)
|
||||||
|
else:
|
||||||
|
await self._callback(processed_chat)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
""" bufferからデータを取り出し、processorに投げ、
|
""" bufferからデータを取り出し、processorに投げ、
|
||||||
加工済みのチャットデータを返す。
|
加工済みのチャットデータを返す。
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
: Processorによって加工されたチャットデータ
|
: Processorによって加工されたチャットデータ
|
||||||
"""
|
"""
|
||||||
if self._callback is None:
|
if self._callback is None:
|
||||||
items = await self._buffer.get()
|
if self.is_alive():
|
||||||
return self.processor.process(items)
|
items = await self._buffer.get()
|
||||||
raise IllegalFunctionCall(
|
return self.processor.process(items)
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
raise exceptions.IllegalFunctionCall(
|
||||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||||
|
|
||||||
|
def is_replay(self):
|
||||||
|
return self._is_replay
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self._callback is None:
|
||||||
|
return
|
||||||
|
if not self._pauser.empty():
|
||||||
|
self._pauser.get_nowait()
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
if self._callback is None:
|
||||||
|
return
|
||||||
|
if self._pauser.empty():
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
|
||||||
def is_alive(self):
|
def is_alive(self):
|
||||||
return self._is_alive
|
return self._is_alive
|
||||||
|
|
||||||
def finish(self,sender):
|
def _finish(self, sender):
|
||||||
'''Listener終了時のコールバック'''
|
'''Listener終了時のコールバック'''
|
||||||
try:
|
try:
|
||||||
self.terminate()
|
self._task_finished()
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
self._logger.debug(f'[{self._video_id}]cancelled:{sender}')
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
|
if self._pauser.empty():
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
self._is_alive = False
|
||||||
|
self._buffer.put_nowait({})
|
||||||
|
self.processor.finalize()
|
||||||
|
|
||||||
|
def _task_finished(self):
|
||||||
'''
|
'''
|
||||||
Listenerを終了する。
|
Listenerを終了する。
|
||||||
'''
|
'''
|
||||||
self._is_alive = False
|
if self.is_alive():
|
||||||
if self._direct_mode == False:
|
self.terminate()
|
||||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
try:
|
||||||
self._buffer.put_nowait({'chatdata':'','timeout':1})
|
self.listen_task.result()
|
||||||
logger.info(f'終了しました:[{self.video_id}]')
|
except Exception as e:
|
||||||
|
self.exception = e
|
||||||
|
if not isinstance(e, exceptions.ChatParseException):
|
||||||
|
self._logger.error(f'Internal exception - {type(e)}{str(e)}')
|
||||||
|
self._logger.info(f'[{self._video_id}]終了しました')
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.exception is not None:
|
||||||
|
raise self.exception
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _set_exception_handler(cls, handler):
|
def _set_exception_handler(cls, handler):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
#default handler: cls._handle_exception
|
|
||||||
loop.set_exception_handler(handler)
|
loop.set_exception_handler(handler)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
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
|
@classmethod
|
||||||
async def shutdown(cls, event, sig = None, handler=None):
|
async def shutdown(cls, event, sig=None, handler=None):
|
||||||
logger.debug("シャットダウンしています")
|
cls._logger.debug("shutdown...")
|
||||||
tasks = [t for t in asyncio.all_tasks() if t is not
|
tasks = [t for t in asyncio.all_tasks() if t is not
|
||||||
asyncio.current_task()]
|
asyncio.current_task()]
|
||||||
[task.cancel() for task in tasks]
|
[task.cancel() for task in tasks]
|
||||||
|
|
||||||
logger.debug(f"残っているタスクを終了しています")
|
cls._logger.debug("complete remaining tasks...")
|
||||||
await asyncio.gather(*tasks,return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.stop()
|
loop.stop()
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
import aiohttp, asyncio
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import signal
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
import urllib.parse
|
|
||||||
from aiohttp.client_exceptions import ClientConnectorError
|
|
||||||
from concurrent.futures import CancelledError
|
|
||||||
from queue import Queue
|
|
||||||
from .buffer import Buffer
|
|
||||||
from ..parser.replay import Parser
|
|
||||||
from .. import config
|
|
||||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
|
||||||
from ..paramgen import arcparam
|
|
||||||
from ..processors.default.processor import DefaultProcessor
|
|
||||||
from ..processors.combinator import Combinator
|
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
MAX_RETRY = 10
|
|
||||||
headers = config.headers
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ReplayChatAsync:
|
|
||||||
'''asyncio(aiohttp)を利用してYouTubeのチャットデータを取得する。
|
|
||||||
|
|
||||||
Parameter
|
|
||||||
---------
|
|
||||||
video_id : str
|
|
||||||
動画ID
|
|
||||||
|
|
||||||
seektime : int
|
|
||||||
リプレイするチャットデータの開始時間(秒)
|
|
||||||
|
|
||||||
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,
|
|
||||||
seektime = 0,
|
|
||||||
processor = DefaultProcessor(),
|
|
||||||
buffer = None,
|
|
||||||
interruptable = True,
|
|
||||||
callback = None,
|
|
||||||
done_callback = None,
|
|
||||||
exception_handler = None,
|
|
||||||
direct_mode = False):
|
|
||||||
self.video_id = video_id
|
|
||||||
self.seektime = seektime
|
|
||||||
if isinstance(processor, tuple):
|
|
||||||
self.processor = Combinator(processor)
|
|
||||||
else:
|
|
||||||
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._parser = Parser()
|
|
||||||
self._pauser = Queue()
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
self._setup()
|
|
||||||
|
|
||||||
if not ReplayChatAsync._setup_finished:
|
|
||||||
ReplayChatAsync._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(
|
|
||||||
ReplayChatAsync.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 = arcparam.get(self.video_id,self.seektime)
|
|
||||||
except ChatParseException as e:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
|
||||||
return
|
|
||||||
except KeyError:
|
|
||||||
logger.debug(f"[{self.video_id}]KeyError:"
|
|
||||||
f"{traceback.format_exc(limit = -1)}")
|
|
||||||
self.terminate()
|
|
||||||
return
|
|
||||||
return initial_continuation
|
|
||||||
|
|
||||||
async def _listen(self, continuation):
|
|
||||||
''' continuationに紐付いたチャットデータを取得し
|
|
||||||
Bufferにチャットデータを格納、
|
|
||||||
次のcontinuaitonを取得してループする。
|
|
||||||
|
|
||||||
Parameter
|
|
||||||
---------
|
|
||||||
continuation : str
|
|
||||||
次のチャットデータ取得に必要なパラメータ
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
while(continuation and self._is_alive):
|
|
||||||
if self._pauser.empty():
|
|
||||||
#pause
|
|
||||||
await self._pauser.get()
|
|
||||||
#resume
|
|
||||||
#prohibit from blocking by putting None into _pauser.
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
livechat_json = (await
|
|
||||||
self._get_livechat_json(continuation, session, headers)
|
|
||||||
)
|
|
||||||
metadata, chatdata = self._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.error(f"{str(e)}(video_id:\"{self.video_id}\")")
|
|
||||||
return
|
|
||||||
except (TypeError , json.JSONDecodeError) :
|
|
||||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
|
||||||
self.terminate()
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
|
||||||
self.terminate()
|
|
||||||
|
|
||||||
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_replay/get_live_chat_replay?"
|
|
||||||
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 pause(self):
|
|
||||||
if not self._pauser.empty():
|
|
||||||
self._pauser.get()
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
if self._pauser.empty():
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
|
||||||
|
|
||||||
class Buffer(queue.Queue):
|
class Buffer(queue.Queue):
|
||||||
'''
|
'''
|
||||||
チャットデータを格納するバッファの役割を持つFIFOキュー
|
チャットデータを格納するバッファの役割を持つFIFOキュー
|
||||||
@@ -11,21 +12,29 @@ class Buffer(queue.Queue):
|
|||||||
格納するチャットブロックの最大個数。0の場合は無限。
|
格納するチャットブロックの最大個数。0の場合は無限。
|
||||||
最大値を超える場合は古いチャットブロックから破棄される。
|
最大値を超える場合は古いチャットブロックから破棄される。
|
||||||
'''
|
'''
|
||||||
def __init__(self,maxsize = 0):
|
|
||||||
|
def __init__(self, maxsize=0):
|
||||||
super().__init__(maxsize=maxsize)
|
super().__init__(maxsize=maxsize)
|
||||||
|
|
||||||
def put(self,item):
|
def put(self, item):
|
||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
if super().full():
|
if super().full():
|
||||||
super().get_nowait()
|
super().get_nowait()
|
||||||
else:
|
else:
|
||||||
super().put(item)
|
super().put(item)
|
||||||
|
|
||||||
|
def put_nowait(self, item):
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
if super().full():
|
||||||
|
super().get_nowait()
|
||||||
|
else:
|
||||||
|
super().put_nowait(item)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
ret = []
|
ret = []
|
||||||
ret.append(super().get())
|
ret.append(super().get())
|
||||||
while not super().empty():
|
while not super().empty():
|
||||||
ret.append(super().get())
|
ret.append(super().get())
|
||||||
return ret
|
return ret
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import requests
|
import requests
|
||||||
import datetime
|
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
||||||
|
from queue import Queue
|
||||||
|
from threading import Event
|
||||||
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 .. import exceptions
|
||||||
from ..paramgen import liveparam
|
from ..paramgen import liveparam, arcparam
|
||||||
from ..processors.default.processor import DefaultProcessor
|
from ..processors.default.processor import DefaultProcessor
|
||||||
from ..processors.combinator import Combinator
|
from ..processors.combinator import Combinator
|
||||||
|
from ..util.extract_video_id import extract_video_id
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
headers = config.headers
|
headers = config.headers
|
||||||
MAX_RETRY = 10
|
MAX_RETRY = 10
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ class LiveChat:
|
|||||||
video_id : str
|
video_id : str
|
||||||
動画ID
|
動画ID
|
||||||
|
|
||||||
|
seektime : int
|
||||||
|
(ライブチャット取得時は無視)
|
||||||
|
取得開始するアーカイブ済みチャットの経過時間(秒)
|
||||||
|
マイナス値を指定した場合は、配信開始前のチャットも取得する。
|
||||||
|
|
||||||
processor : ChatProcessor
|
processor : ChatProcessor
|
||||||
チャットデータを加工するオブジェクト
|
チャットデータを加工するオブジェクト
|
||||||
|
|
||||||
@@ -48,7 +53,14 @@ class LiveChat:
|
|||||||
direct_mode : bool
|
direct_mode : bool
|
||||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||||
Trueの場合、callbackの設定が必須
|
Trueの場合、callbackの設定が必須
|
||||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||||
|
|
||||||
|
force_replay : bool
|
||||||
|
Trueの場合、ライブチャットが取得できる場合であっても
|
||||||
|
強制的にアーカイブ済みチャットを取得する。
|
||||||
|
|
||||||
|
topchat_only : bool
|
||||||
|
Trueの場合、上位チャットのみ取得する。
|
||||||
|
|
||||||
Attributes
|
Attributes
|
||||||
---------
|
---------
|
||||||
@@ -60,17 +72,21 @@ class LiveChat:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
_setup_finished = False
|
_setup_finished = False
|
||||||
#チャット監視中のListenerのリスト
|
|
||||||
_listeners= []
|
|
||||||
def __init__(self, video_id,
|
def __init__(self, video_id,
|
||||||
processor = DefaultProcessor(),
|
seektime=-1,
|
||||||
buffer = None,
|
processor=DefaultProcessor(),
|
||||||
interruptable = True,
|
buffer=None,
|
||||||
callback = None,
|
interruptable=True,
|
||||||
done_callback = None,
|
callback=None,
|
||||||
direct_mode = False
|
done_callback=None,
|
||||||
):
|
direct_mode=False,
|
||||||
self.video_id = video_id
|
force_replay=False,
|
||||||
|
topchat_only=False,
|
||||||
|
logger=config.logger(__name__)
|
||||||
|
):
|
||||||
|
self._video_id = extract_video_id(video_id)
|
||||||
|
self.seektime = seektime
|
||||||
if isinstance(processor, tuple):
|
if isinstance(processor, tuple):
|
||||||
self.processor = Combinator(processor)
|
self.processor = Combinator(processor)
|
||||||
else:
|
else:
|
||||||
@@ -80,144 +96,167 @@ 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._parser = Parser()
|
self._is_replay = force_replay
|
||||||
|
self._parser = Parser(is_replay=self._is_replay)
|
||||||
|
self._pauser = Queue()
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
self._first_fetch = True
|
||||||
|
self._fetch_url = "live_chat/get_live_chat?continuation="
|
||||||
|
self._topchat_only = topchat_only
|
||||||
|
self._event = Event()
|
||||||
|
self._logger = logger
|
||||||
|
self.exception = None
|
||||||
|
if interruptable:
|
||||||
|
signal.signal(signal.SIGINT, lambda a, b: self.terminate())
|
||||||
self._setup()
|
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):
|
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 exceptions.IllegalFunctionCall(
|
||||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
"When direct_mode=True, callback parameter is required.")
|
||||||
else:
|
else:
|
||||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
# direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||||
if self._buffer is None:
|
if self._buffer is None:
|
||||||
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)
|
self.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)
|
self.listen_task.add_done_callback(self._finish)
|
||||||
else:
|
else:
|
||||||
listen_task.add_done_callback(self._done_callback)
|
self.listen_task.add_done_callback(self._done_callback)
|
||||||
|
|
||||||
def _startlisten(self):
|
def _startlisten(self):
|
||||||
"""最初のcontinuationパラメータを取得し、
|
time.sleep(0.1) # sleep shortly to prohibit skipping fetching data
|
||||||
_listenループのタスクを作成し開始する
|
"""Fetch first continuation parameter,
|
||||||
|
create and start _listen loop.
|
||||||
"""
|
"""
|
||||||
initial_continuation = self._get_initial_continuation()
|
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||||
if initial_continuation is None:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
|
||||||
return
|
|
||||||
self._listen(initial_continuation)
|
self._listen(initial_continuation)
|
||||||
|
|
||||||
def _get_initial_continuation(self):
|
|
||||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
|
||||||
try:
|
|
||||||
initial_continuation = liveparam.getparam(self.video_id)
|
|
||||||
except ChatParseException as e:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
|
||||||
return
|
|
||||||
except KeyError:
|
|
||||||
logger.debug(f"[{self.video_id}]KeyError:"
|
|
||||||
f"{traceback.format_exc(limit = -1)}")
|
|
||||||
self.terminate()
|
|
||||||
return
|
|
||||||
return initial_continuation
|
|
||||||
|
|
||||||
def _listen(self, continuation):
|
def _listen(self, continuation):
|
||||||
''' continuationに紐付いたチャットデータを取得し
|
''' Fetch chat data and store them into buffer,
|
||||||
Bufferにチャットデータを格納、
|
get next continuaiton parameter and loop.
|
||||||
次のcontinuaitonを取得してループする。
|
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
continuation : str
|
continuation : str
|
||||||
次のチャットデータ取得に必要なパラメータ
|
parameter for next chat data
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
with requests.Session() as session:
|
with requests.Session() as session:
|
||||||
while(continuation and self._is_alive):
|
while(continuation and self._is_alive):
|
||||||
livechat_json = (
|
continuation = self._check_pause(continuation)
|
||||||
self._get_livechat_json(continuation, session, headers)
|
contents = self._get_contents(continuation, session, headers)
|
||||||
)
|
metadata, chatdata = self._parser.parse(contents)
|
||||||
metadata, chatdata = self._parser.parse( livechat_json )
|
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:
|
||||||
self._callback(
|
processed_chat = self.processor.process(
|
||||||
self.processor.process([chat_component])
|
[chat_component])
|
||||||
)
|
if isinstance(processed_chat, tuple):
|
||||||
|
self._callback(*processed_chat)
|
||||||
|
else:
|
||||||
|
self._callback(processed_chat)
|
||||||
else:
|
else:
|
||||||
self._buffer.put(chat_component)
|
self._buffer.put(chat_component)
|
||||||
diff_time = timeout - (time.time()-time_mark)
|
diff_time = timeout - (time.time() - time_mark)
|
||||||
if diff_time < 0 : diff_time=0
|
self._event.wait(diff_time if diff_time > 0 else 0)
|
||||||
time.sleep(diff_time)
|
continuation = metadata.get('continuation')
|
||||||
continuation = metadata.get('continuation')
|
except exceptions.ChatParseException as e:
|
||||||
except ChatParseException as e:
|
self._logger.debug(f"[{self._video_id}]{str(e)}")
|
||||||
self.terminate()
|
raise
|
||||||
logger.error(f"{str(e)}(video_id:\"{self.video_id}\")")
|
except (TypeError, json.JSONDecodeError):
|
||||||
return
|
self._logger.error(f"{traceback.format_exc(limit=-1)}")
|
||||||
except (TypeError , json.JSONDecodeError) :
|
raise
|
||||||
self.terminate()
|
|
||||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
self._logger.debug(f"[{self._video_id}]finished fetching chat.")
|
||||||
return
|
raise exceptions.ChatDataFinished
|
||||||
|
|
||||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
def _check_pause(self, continuation):
|
||||||
|
if self._pauser.empty():
|
||||||
|
'''pause'''
|
||||||
|
self._pauser.get()
|
||||||
|
'''resume:
|
||||||
|
prohibit from blocking by putting None into _pauser.
|
||||||
|
'''
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
if not self._is_replay:
|
||||||
|
continuation = liveparam.getparam(self._video_id, 3)
|
||||||
|
return continuation
|
||||||
|
|
||||||
|
def _get_contents(self, continuation, session, headers):
|
||||||
|
'''Get 'continuationContents' from livechat json.
|
||||||
|
If contents is None at first fetching,
|
||||||
|
try to fetch archive chat data.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
-------
|
||||||
|
'continuationContents' which includes metadata & chat data.
|
||||||
|
'''
|
||||||
|
livechat_json = (
|
||||||
|
self._get_livechat_json(continuation, session, headers)
|
||||||
|
)
|
||||||
|
contents = self._parser.get_contents(livechat_json)
|
||||||
|
if self._first_fetch:
|
||||||
|
if contents is None or self._is_replay:
|
||||||
|
'''Try to fetch archive chat data.'''
|
||||||
|
self._parser.is_replay = True
|
||||||
|
self._fetch_url = "live_chat_replay/get_live_chat_replay?continuation="
|
||||||
|
continuation = arcparam.getparam(
|
||||||
|
self._video_id, self.seektime, self._topchat_only)
|
||||||
|
livechat_json = (self._get_livechat_json(continuation, session, headers))
|
||||||
|
reload_continuation = self._parser.reload_continuation(
|
||||||
|
self._parser.get_contents(livechat_json))
|
||||||
|
if reload_continuation:
|
||||||
|
livechat_json = (self._get_livechat_json(
|
||||||
|
reload_continuation, session, headers))
|
||||||
|
contents = self._parser.get_contents(livechat_json)
|
||||||
|
self._is_replay = True
|
||||||
|
self._first_fetch = False
|
||||||
|
return contents
|
||||||
|
|
||||||
def _get_livechat_json(self, continuation, session, headers):
|
def _get_livechat_json(self, continuation, session, headers):
|
||||||
'''
|
'''
|
||||||
チャットデータが格納されたjsonデータを取得する。
|
Get json which includes chat data.
|
||||||
'''
|
'''
|
||||||
continuation = urllib.parse.quote(continuation)
|
continuation = urllib.parse.quote(continuation)
|
||||||
livechat_json = None
|
livechat_json = None
|
||||||
status_code = 0
|
status_code = 0
|
||||||
url =(
|
url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||||
f"https://www.youtube.com/live_chat/get_live_chat?"
|
|
||||||
f"continuation={continuation}&pbj=1")
|
|
||||||
for _ in range(MAX_RETRY + 1):
|
for _ in range(MAX_RETRY + 1):
|
||||||
with session.get(url ,headers = headers) as resp:
|
with session.get(url, headers=headers) as resp:
|
||||||
try:
|
try:
|
||||||
text = resp.text
|
text = resp.text
|
||||||
status_code = resp.status_code
|
|
||||||
livechat_json = json.loads(text)
|
livechat_json = json.loads(text)
|
||||||
break
|
break
|
||||||
except json.JSONDecodeError :
|
except json.JSONDecodeError:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
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}")
|
||||||
self.terminate()
|
raise exceptions.RetryExceedMaxCount()
|
||||||
return None
|
|
||||||
return livechat_json
|
return livechat_json
|
||||||
|
|
||||||
def _callback_loop(self,callback):
|
def _callback_loop(self, callback):
|
||||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
callback : func
|
callback : func
|
||||||
@@ -225,44 +264,75 @@ class LiveChat:
|
|||||||
"""
|
"""
|
||||||
while self.is_alive():
|
while self.is_alive():
|
||||||
items = self._buffer.get()
|
items = self._buffer.get()
|
||||||
data = self.processor.process(items)
|
processed_chat = self.processor.process(items)
|
||||||
callback(data)
|
if isinstance(processed_chat, tuple):
|
||||||
|
self._callback(*processed_chat)
|
||||||
|
else:
|
||||||
|
self._callback(processed_chat)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
""" bufferからデータを取り出し、processorに投げ、
|
""" bufferからデータを取り出し、processorに投げ、
|
||||||
加工済みのチャットデータを返す。
|
加工済みのチャットデータを返す。
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
: Processorによって加工されたチャットデータ
|
: Processorによって加工されたチャットデータ
|
||||||
"""
|
"""
|
||||||
if self._callback is None:
|
if self._callback is None:
|
||||||
items = self._buffer.get()
|
if self.is_alive():
|
||||||
return self.processor.process(items)
|
items = self._buffer.get()
|
||||||
raise IllegalFunctionCall(
|
return self.processor.process(items)
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
raise exceptions.IllegalFunctionCall(
|
||||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||||
|
|
||||||
|
def is_replay(self):
|
||||||
|
return self._is_replay
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self._callback is None:
|
||||||
|
return
|
||||||
|
if not self._pauser.empty():
|
||||||
|
self._pauser.get()
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
if self._callback is None:
|
||||||
|
return
|
||||||
|
if self._pauser.empty():
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
|
||||||
def is_alive(self):
|
def is_alive(self):
|
||||||
return self._is_alive
|
return self._is_alive
|
||||||
|
|
||||||
def finish(self,sender):
|
def _finish(self, sender):
|
||||||
'''Listener終了時のコールバック'''
|
'''Listener終了時のコールバック'''
|
||||||
try:
|
try:
|
||||||
self.terminate()
|
self._task_finished()
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
self._logger.debug(f'[{self._video_id}]cancelled:{sender}')
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
|
if self._pauser.empty():
|
||||||
|
self._pauser.put_nowait(None)
|
||||||
|
self._is_alive = False
|
||||||
|
self._buffer.put({})
|
||||||
|
self._event.set()
|
||||||
|
self.processor.finalize()
|
||||||
|
|
||||||
|
def _task_finished(self):
|
||||||
'''
|
'''
|
||||||
Listenerを終了する。
|
Listenerを終了する。
|
||||||
'''
|
'''
|
||||||
self._is_alive = False
|
if self.is_alive():
|
||||||
if self._direct_mode == False:
|
self.terminate()
|
||||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
try:
|
||||||
self._buffer.put({'chatdata':'','timeout':1})
|
self.listen_task.result()
|
||||||
logger.info(f'[{self.video_id}]終了しました')
|
except Exception as e:
|
||||||
|
self.exception = e
|
||||||
@classmethod
|
if not isinstance(e, exceptions.ChatParseException):
|
||||||
def shutdown(cls, event, sig = None, handler=None):
|
self._logger.error(f'Internal exception - {type(e)}{str(e)}')
|
||||||
logger.debug("シャットダウンしています")
|
self._logger.info(f'[{self._video_id}]終了しました')
|
||||||
for t in LiveChat._listeners:
|
|
||||||
t._is_alive = False
|
def raise_for_status(self):
|
||||||
|
if self.exception is not None:
|
||||||
|
raise self.exception
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
import requests
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import signal
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
import urllib.parse
|
|
||||||
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
|
||||||
from queue import Queue
|
|
||||||
from .buffer import Buffer
|
|
||||||
from ..parser.replay import Parser
|
|
||||||
from .. import config
|
|
||||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
|
||||||
from ..paramgen import arcparam
|
|
||||||
from ..processors.default.processor import DefaultProcessor
|
|
||||||
from ..processors.combinator import Combinator
|
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
headers = config.headers
|
|
||||||
MAX_RETRY = 10
|
|
||||||
|
|
||||||
|
|
||||||
class ReplayChat:
|
|
||||||
''' スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する
|
|
||||||
|
|
||||||
Parameter
|
|
||||||
---------
|
|
||||||
video_id : str
|
|
||||||
動画ID
|
|
||||||
|
|
||||||
seektime : int
|
|
||||||
リプレイするチャットデータの開始時間(秒)
|
|
||||||
|
|
||||||
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,
|
|
||||||
seektime = 0,
|
|
||||||
processor = DefaultProcessor(),
|
|
||||||
buffer = None,
|
|
||||||
interruptable = True,
|
|
||||||
callback = None,
|
|
||||||
done_callback = None,
|
|
||||||
direct_mode = False
|
|
||||||
):
|
|
||||||
self.video_id = video_id
|
|
||||||
self.seektime = seektime
|
|
||||||
if isinstance(processor, tuple):
|
|
||||||
self.processor = Combinator(processor)
|
|
||||||
else:
|
|
||||||
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._pauser = Queue()
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
|
|
||||||
self._setup()
|
|
||||||
|
|
||||||
if not ReplayChat._setup_finished:
|
|
||||||
ReplayChat._setup_finished = True
|
|
||||||
if interruptable:
|
|
||||||
signal.signal(signal.SIGINT, (lambda a, b:
|
|
||||||
(ReplayChat.shutdown(None,signal.SIGINT,b))
|
|
||||||
))
|
|
||||||
ReplayChat._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 = arcparam.get(self.video_id,self.seektime)
|
|
||||||
except ChatParseException as e:
|
|
||||||
self.terminate()
|
|
||||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
|
||||||
return
|
|
||||||
except KeyError:
|
|
||||||
logger.debug(f"[{self.video_id}]KeyError:"
|
|
||||||
f"{traceback.format_exc(limit = -1)}")
|
|
||||||
self.terminate()
|
|
||||||
return
|
|
||||||
return initial_continuation
|
|
||||||
|
|
||||||
def _listen(self, continuation):
|
|
||||||
''' continuationに紐付いたチャットデータを取得し
|
|
||||||
BUfferにチャットデータを格納、
|
|
||||||
次のcontinuaitonを取得してループする
|
|
||||||
|
|
||||||
Parameter
|
|
||||||
---------
|
|
||||||
continuation : str
|
|
||||||
次のチャットデータ取得に必要なパラメータ
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
with requests.Session() as session:
|
|
||||||
while(continuation and self._is_alive):
|
|
||||||
if self._pauser.empty():
|
|
||||||
#pause
|
|
||||||
self._pauser.get()
|
|
||||||
#resume
|
|
||||||
#prohibit from blocking by putting None into _pauser.
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
livechat_json = (
|
|
||||||
self._get_livechat_json(continuation, session, headers)
|
|
||||||
)
|
|
||||||
metadata, chatdata = self._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:
|
|
||||||
self._callback(
|
|
||||||
self.processor.process([chat_component])
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._buffer.put(chat_component)
|
|
||||||
diff_time = timeout - (time.time()-time_mark)
|
|
||||||
if diff_time < 0 : diff_time=0
|
|
||||||
time.sleep(diff_time)
|
|
||||||
continuation = metadata.get('continuation')
|
|
||||||
except ChatParseException as e:
|
|
||||||
self.terminate()
|
|
||||||
logger.error(f"{str(e)}(video_id:\"{self.video_id}\")")
|
|
||||||
return
|
|
||||||
except (TypeError , json.JSONDecodeError) :
|
|
||||||
self.terminate()
|
|
||||||
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_replay/get_live_chat_replay?"
|
|
||||||
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}")
|
|
||||||
self.terminate()
|
|
||||||
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 pause(self):
|
|
||||||
if not self._pauser.empty():
|
|
||||||
self._pauser.get()
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
if self._pauser.empty():
|
|
||||||
self._pauser.put_nowait(None)
|
|
||||||
|
|
||||||
|
|
||||||
def is_alive(self):
|
|
||||||
return self._is_alive
|
|
||||||
|
|
||||||
def finish(self,sender):
|
|
||||||
'''Listener終了時のコールバック'''
|
|
||||||
try:
|
|
||||||
self.terminate()
|
|
||||||
except RuntimeError:
|
|
||||||
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 ReplayChat._listeners:
|
|
||||||
t._is_alive = False
|
|
||||||
@@ -1,43 +1,64 @@
|
|||||||
class ChatParseException(Exception):
|
class ChatParseException(Exception):
|
||||||
'''
|
'''
|
||||||
チャットデータをパースするライブラリが投げる例外の基底クラス
|
Base exception thrown by the parser
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class NoYtinitialdataException(ChatParseException):
|
|
||||||
'''
|
|
||||||
配信ページ内にチャットデータurlが見つからないときに投げる例外
|
|
||||||
'''
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ResponseContextError(ChatParseException):
|
class ResponseContextError(ChatParseException):
|
||||||
'''
|
'''
|
||||||
配信ページでチャットデータ無効の時に投げる例外
|
Thrown when chat data is invalid.
|
||||||
'''
|
|
||||||
pass
|
|
||||||
|
|
||||||
class NoLivechatRendererException(ChatParseException):
|
|
||||||
'''
|
|
||||||
チャットデータのJSON中にlivechatRendererがない時に投げる例外
|
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NoContentsException(ChatParseException):
|
class NoContents(ChatParseException):
|
||||||
'''
|
'''
|
||||||
チャットデータのJSON中にContinuationContentsがない時に投げる例外
|
Thrown when ContinuationContents is missing in JSON.
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class NoContinuationsException(ChatParseException):
|
|
||||||
|
class NoContinuation(ChatParseException):
|
||||||
'''
|
'''
|
||||||
チャットデータのContinuationContents中にcontinuationがない時に投げる例外
|
Thrown when continuation is missing in ContinuationContents.
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class IllegalFunctionCall(Exception):
|
class IllegalFunctionCall(Exception):
|
||||||
'''
|
'''
|
||||||
set_callback()を実行済みにもかかわらず
|
Thrown when get() is called even though
|
||||||
get()を呼び出した場合の例外
|
set_callback() has been executed.
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidVideoIdException(Exception):
|
||||||
|
'''
|
||||||
|
Thrown when the video_id is not exist (VideoInfo).
|
||||||
|
'''
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownConnectionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExceedMaxCount(Exception):
|
||||||
|
'''
|
||||||
|
thrown when the number of retries exceeds the maximum value.
|
||||||
|
'''
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatDataFinished(ChatParseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReceivedUnknownContinuation(ChatParseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FailedExtractContinuation(ChatDataFinished):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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", encoding='utf-8')
|
|
||||||
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
|
|
||||||
@@ -1,120 +1,62 @@
|
|||||||
from base64 import urlsafe_b64encode as b64enc
|
from .pb.header_pb2 import Header
|
||||||
from functools import reduce
|
from .pb.replay_pb2 import Continuation
|
||||||
import calendar, datetime, pytz
|
from urllib.parse import quote
|
||||||
import math
|
import base64
|
||||||
import random
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Generate continuation parameter of youtube replay chat.
|
Generate continuation parameter of youtube replay chat.
|
||||||
|
|
||||||
Author: taizan-hokuto (2019) @taizan205
|
Author: taizan-hokuto
|
||||||
|
|
||||||
ver 0.0.1 2019.10.05
|
ver 0.0.1 2019.10.05 : Initial release.
|
||||||
|
ver 0.0.2 2020.05.30 : Use Protocol Buffers.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def _gen_vid(video_id):
|
|
||||||
"""generate video_id parameter.
|
def _gen_vid(video_id) -> str:
|
||||||
|
header = Header()
|
||||||
|
header.info.video.id = video_id
|
||||||
|
header.terminator = 1
|
||||||
|
return base64.urlsafe_b64encode(header.SerializeToString()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _build(video_id, seektime, topchat_only) -> str:
|
||||||
|
chattype = 1
|
||||||
|
timestamp = 0
|
||||||
|
if topchat_only:
|
||||||
|
chattype = 4
|
||||||
|
|
||||||
|
fetch_before_start = 3
|
||||||
|
if seektime < 0:
|
||||||
|
fetch_before_start = 4
|
||||||
|
elif seektime == 0:
|
||||||
|
timestamp = 1
|
||||||
|
else:
|
||||||
|
timestamp = int(seektime * 1000000)
|
||||||
|
continuation = Continuation()
|
||||||
|
entity = continuation.entity
|
||||||
|
entity.header = _gen_vid(video_id)
|
||||||
|
entity.timestamp = timestamp
|
||||||
|
entity.s6 = 0
|
||||||
|
entity.s7 = 0
|
||||||
|
entity.s8 = 0
|
||||||
|
entity.s9 = fetch_before_start
|
||||||
|
entity.s10 = ''
|
||||||
|
entity.s12 = chattype
|
||||||
|
entity.chattype.value = chattype
|
||||||
|
entity.s15 = 0
|
||||||
|
return quote(
|
||||||
|
base64.urlsafe_b64encode(continuation.SerializeToString()).decode())
|
||||||
|
|
||||||
|
|
||||||
|
def getparam(video_id, seektime=-1, topchat_only=False) -> str:
|
||||||
|
'''
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
video_id : str
|
seektime : int
|
||||||
|
unit:seconds
|
||||||
Return
|
start position of fetching chat data.
|
||||||
---------
|
topchat_only : bool
|
||||||
byte[] : base64 encoded video_id parameter.
|
if True, fetch only 'top chat'
|
||||||
"""
|
'''
|
||||||
header_magic = b'\x0A\x0F\x1A\x0D\x0A'
|
return _build(video_id, seektime, topchat_only)
|
||||||
header_id = video_id.encode()
|
|
||||||
header_sep_1 = b'\x1A\x13\xEA\xA8\xDD\xB9\x01\x0D\x0A\x0B'
|
|
||||||
header_terminator = b'\x20\x01'
|
|
||||||
|
|
||||||
item = [
|
|
||||||
header_magic,
|
|
||||||
_nval(len(header_id)),
|
|
||||||
header_id,
|
|
||||||
header_sep_1,
|
|
||||||
header_id,
|
|
||||||
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 _tzparity(video_id,times):
|
|
||||||
t=0
|
|
||||||
for i,s in enumerate(video_id):
|
|
||||||
ss = ord(s)
|
|
||||||
if(ss % 2 == 0):
|
|
||||||
t += ss*(12-i)
|
|
||||||
else:
|
|
||||||
t ^= ss*i
|
|
||||||
|
|
||||||
return ((times^t) % 2).to_bytes(1,'big')
|
|
||||||
|
|
||||||
|
|
||||||
def get(video_id, seektime = 0, topchatonly = False):
|
|
||||||
switch_01 = b'\x04' if topchatonly else b'\x01'
|
|
||||||
|
|
||||||
|
|
||||||
if seektime < 0:
|
|
||||||
raise ValueError('seektime is 0 or positive number.')
|
|
||||||
if seektime == 0:
|
|
||||||
times =_nval(1)
|
|
||||||
switch = b'\x04'
|
|
||||||
else:
|
|
||||||
times =_nval(int(seektime*1000000))
|
|
||||||
switch = b'\x03'
|
|
||||||
parity = _tzparity(video_id, seektime)
|
|
||||||
|
|
||||||
header_magic= b'\xA2\x9D\xB0\xD3\x04'
|
|
||||||
sep_0 = b'\x1A'
|
|
||||||
vid = _gen_vid(video_id)
|
|
||||||
time_tag = b'\x28'
|
|
||||||
timestamp1 = times
|
|
||||||
sep_1 = b'\x30\x00\x38\x00\x40\x00\x48'
|
|
||||||
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'
|
|
||||||
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 = [
|
|
||||||
sep_0,
|
|
||||||
_nval(len(vid)),
|
|
||||||
vid,
|
|
||||||
time_tag,
|
|
||||||
timestamp1,
|
|
||||||
sep_1,
|
|
||||||
switch,
|
|
||||||
sep_2,
|
|
||||||
chkstr,
|
|
||||||
sep_3,
|
|
||||||
switch_01,
|
|
||||||
sep_4,
|
|
||||||
switch_01,
|
|
||||||
sep_5
|
|
||||||
]
|
|
||||||
|
|
||||||
body = reduce(lambda x, y: x+y, body)
|
|
||||||
|
|
||||||
return urllib.parse.quote(
|
|
||||||
b64enc( header_magic +
|
|
||||||
_nval(len(body)) +
|
|
||||||
body
|
|
||||||
).decode()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
133
pytchat/paramgen/arcparam_mining.py
Normal file
133
pytchat/paramgen/arcparam_mining.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from base64 import urlsafe_b64encode as b64enc
|
||||||
|
from functools import reduce
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
'''
|
||||||
|
Generate continuation parameter of youtube replay chat.
|
||||||
|
|
||||||
|
Author: taizan-hokuto (2019) @taizan205
|
||||||
|
|
||||||
|
ver 0.0.1 2019.10.05
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_vid_long(video_id):
|
||||||
|
"""generate video_id parameter.
|
||||||
|
Parameter
|
||||||
|
---------
|
||||||
|
video_id : str
|
||||||
|
|
||||||
|
Return
|
||||||
|
---------
|
||||||
|
byte[] : base64 encoded video_id parameter.
|
||||||
|
"""
|
||||||
|
header_magic = b'\x0A\x0F\x1A\x0D\x0A'
|
||||||
|
header_id = video_id.encode()
|
||||||
|
header_sep_1 = b'\x1A\x13\xEA\xA8\xDD\xB9\x01\x0D\x0A\x0B'
|
||||||
|
header_terminator = b'\x20\x01'
|
||||||
|
|
||||||
|
item = [
|
||||||
|
header_magic,
|
||||||
|
_nval(len(header_id)),
|
||||||
|
header_id,
|
||||||
|
header_sep_1,
|
||||||
|
header_id,
|
||||||
|
header_terminator
|
||||||
|
]
|
||||||
|
|
||||||
|
return urllib.parse.quote(
|
||||||
|
b64enc(reduce(lambda x, y: x + y, item)).decode()
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_vid(video_id):
|
||||||
|
"""generate video_id parameter.
|
||||||
|
Parameter
|
||||||
|
---------
|
||||||
|
video_id : str
|
||||||
|
|
||||||
|
Return
|
||||||
|
---------
|
||||||
|
bytes : base64 encoded video_id parameter.
|
||||||
|
"""
|
||||||
|
header_magic = b'\x0A\x0F\x1A\x0D\x0A'
|
||||||
|
header_id = video_id.encode()
|
||||||
|
header_terminator = b'\x20\x01'
|
||||||
|
|
||||||
|
item = [
|
||||||
|
header_magic,
|
||||||
|
_nval(len(header_id)),
|
||||||
|
header_id,
|
||||||
|
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, seektime, topchat_only):
|
||||||
|
switch_01 = b'\x04' if topchat_only else b'\x01'
|
||||||
|
if seektime < 0:
|
||||||
|
raise ValueError("seektime must be greater than or equal to zero.")
|
||||||
|
if seektime == 0:
|
||||||
|
times = b''
|
||||||
|
else:
|
||||||
|
times = _nval(int(seektime * 1000))
|
||||||
|
if seektime > 0:
|
||||||
|
_len_time = b'\x5A' + (len(times) + 1).to_bytes(1, 'big') + b'\x10'
|
||||||
|
else:
|
||||||
|
_len_time = b''
|
||||||
|
|
||||||
|
header_magic = b'\xA2\x9D\xB0\xD3\x04'
|
||||||
|
sep_0 = b'\x1A'
|
||||||
|
vid = _gen_vid(video_id)
|
||||||
|
_tag = b'\x40\x01'
|
||||||
|
timestamp1 = times
|
||||||
|
sep_1 = b'\x60\x04\x72\x02\x08'
|
||||||
|
terminator = b'\x78\x01'
|
||||||
|
|
||||||
|
body = [
|
||||||
|
sep_0,
|
||||||
|
_nval(len(vid)),
|
||||||
|
vid,
|
||||||
|
_tag,
|
||||||
|
_len_time,
|
||||||
|
timestamp1,
|
||||||
|
sep_1,
|
||||||
|
switch_01,
|
||||||
|
terminator
|
||||||
|
]
|
||||||
|
|
||||||
|
body = reduce(lambda x, y: x + y, body)
|
||||||
|
|
||||||
|
return urllib.parse.quote(
|
||||||
|
b64enc(header_magic + _nval(len(body)) + body
|
||||||
|
).decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def getparam(video_id, seektime=0.0, topchat_only=False):
|
||||||
|
'''
|
||||||
|
Parameter
|
||||||
|
---------
|
||||||
|
seektime : int
|
||||||
|
unit:seconds
|
||||||
|
start position of fetching chat data.
|
||||||
|
topchat_only : bool
|
||||||
|
if True, fetch only 'top chat'
|
||||||
|
'''
|
||||||
|
return _build(video_id, seektime, topchat_only)
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
from base64 import urlsafe_b64encode as b64enc
|
from .pb.header_pb2 import Header
|
||||||
from functools import reduce
|
from .pb.live_pb2 import Continuation
|
||||||
import calendar, datetime, pytz
|
from urllib.parse import quote
|
||||||
|
import base64
|
||||||
import random
|
import random
|
||||||
import urllib.parse
|
import time
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Generate continuation parameter of youtube live chat.
|
Generate continuation parameter of youtube live chat.
|
||||||
|
|
||||||
Author: taizan-hokuto (2019) @taizan205
|
Author: taizan-hokuto
|
||||||
|
|
||||||
ver 0.0.1 2019.10.05
|
ver 0.0.1 2019.10.05 : Initial release.
|
||||||
|
ver 0.0.2 2020.05.30 : Use Protocol Buffers.
|
||||||
'''
|
'''
|
||||||
def _gen_vid(video_id):
|
|
||||||
|
|
||||||
|
def _gen_vid(video_id) -> str:
|
||||||
"""generate video_id parameter.
|
"""generate video_id parameter.
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
@@ -19,148 +23,66 @@ def _gen_vid(video_id):
|
|||||||
|
|
||||||
Return
|
Return
|
||||||
---------
|
---------
|
||||||
byte[] : base64 encoded video_id parameter.
|
str : base64 encoded video_id parameter.
|
||||||
"""
|
"""
|
||||||
header_magic = b'\x0A\x0F\x0A\x0D\x0A'
|
header = Header()
|
||||||
header_id = video_id.encode()
|
header.info.video.id = video_id
|
||||||
header_sep_1 = b'\x1A'
|
header.terminator = 1
|
||||||
header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A'
|
return base64.urlsafe_b64encode(header.SerializeToString()).decode()
|
||||||
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(
|
def _build(video_id, ts1, ts2, ts3, ts4, ts5, topchat_only) -> str:
|
||||||
b64enc(reduce(lambda x, y: x+y, item)).decode()
|
chattype = 1
|
||||||
).encode()
|
if topchat_only:
|
||||||
|
chattype = 4
|
||||||
|
continuation = Continuation()
|
||||||
|
entity = continuation.entity
|
||||||
|
|
||||||
def _tzparity(video_id,times):
|
entity.header = _gen_vid(video_id)
|
||||||
t=0
|
entity.timestamp1 = ts1
|
||||||
for i,s in enumerate(video_id):
|
entity.s6 = 0
|
||||||
ss = ord(s)
|
entity.s7 = 0
|
||||||
if(ss % 2 == 0):
|
entity.s8 = 1
|
||||||
t += ss*(12-i)
|
entity.body.b1 = 0
|
||||||
else:
|
entity.body.b2 = 0
|
||||||
t ^= ss*i
|
entity.body.b3 = 0
|
||||||
|
entity.body.b4 = 0
|
||||||
|
entity.body.b7 = ''
|
||||||
|
entity.body.b8 = 0
|
||||||
|
entity.body.b9 = ''
|
||||||
|
entity.body.timestamp2 = ts2
|
||||||
|
entity.body.b11 = 3
|
||||||
|
entity.body.b15 = 0
|
||||||
|
entity.timestamp3 = ts3
|
||||||
|
entity.timestamp4 = ts4
|
||||||
|
entity.s13 = chattype
|
||||||
|
entity.chattype.value = chattype
|
||||||
|
entity.s17 = 0
|
||||||
|
entity.str19.value = 0
|
||||||
|
entity.timestamp5 = ts5
|
||||||
|
|
||||||
return ((times^t) % 2).to_bytes(1,'big')
|
return quote(
|
||||||
|
base64.urlsafe_b64encode(continuation.SerializeToString()).decode()
|
||||||
|
)
|
||||||
|
|
||||||
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'
|
|
||||||
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'
|
|
||||||
timestamp1 = _nval(_ts1)
|
|
||||||
sep_1 = b'\x30\x00\x38\x00\x40\x02\x4A'
|
|
||||||
un_len = b'\x2B'
|
|
||||||
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'
|
|
||||||
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(past_sec):
|
def _times(past_sec):
|
||||||
|
n = int(time.time())
|
||||||
def unixts_now():
|
_ts1 = n - random.uniform(0, 1 * 3)
|
||||||
now = datetime.datetime.now(pytz.utc)
|
_ts2 = n - random.uniform(0.01, 0.99)
|
||||||
return calendar.timegm(now.utctimetuple())
|
_ts3 = n - past_sec + random.uniform(0, 1)
|
||||||
|
_ts4 = n - random.uniform(10 * 60, 60 * 60)
|
||||||
n = unixts_now()
|
_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 = 60):
|
def getparam(video_id, past_sec=0, topchat_only=False) -> str:
|
||||||
'''
|
'''
|
||||||
Parameter
|
Parameter
|
||||||
---------
|
---------
|
||||||
past_sec : int
|
past_sec : int
|
||||||
seconds to load past chat data
|
seconds to load past chat data
|
||||||
|
topchat_only : bool
|
||||||
|
if True, fetch only 'top chat'
|
||||||
'''
|
'''
|
||||||
return _build(video_id,*_times(past_sec))
|
return _build(video_id, *_times(past_sec), topchat_only)
|
||||||
|
|
||||||
|
|||||||
0
pytchat/paramgen/pb/__init__.py
Normal file
0
pytchat/paramgen/pb/__init__.py
Normal file
159
pytchat/paramgen/pb/header_pb2.py
Normal file
159
pytchat/paramgen/pb/header_pb2.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# source: header.proto
|
||||||
|
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import message as _message
|
||||||
|
from google.protobuf import reflection as _reflection
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||||
|
name='header.proto',
|
||||||
|
package='',
|
||||||
|
syntax='proto3',
|
||||||
|
serialized_options=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
serialized_pb=b'\n\x0cheader.proto\"\x13\n\x05Video\x12\n\n\x02id\x18\x01 \x01(\t\"#\n\nHeaderInfo\x12\x15\n\x05video\x18\x01 \x01(\x0b\x32\x06.Video\"7\n\x06Header\x12\x19\n\x04info\x18\x01 \x01(\x0b\x32\x0b.HeaderInfo\x12\x12\n\nterminator\x18\x04 \x01(\x05\x62\x06proto3'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_VIDEO = _descriptor.Descriptor(
|
||||||
|
name='Video',
|
||||||
|
full_name='Video',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='id', full_name='Video.id', index=0,
|
||||||
|
number=1, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=16,
|
||||||
|
serialized_end=35,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HEADERINFO = _descriptor.Descriptor(
|
||||||
|
name='HeaderInfo',
|
||||||
|
full_name='HeaderInfo',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='video', full_name='HeaderInfo.video', index=0,
|
||||||
|
number=1, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=37,
|
||||||
|
serialized_end=72,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HEADER = _descriptor.Descriptor(
|
||||||
|
name='Header',
|
||||||
|
full_name='Header',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='info', full_name='Header.info', index=0,
|
||||||
|
number=1, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='terminator', full_name='Header.terminator', index=1,
|
||||||
|
number=4, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=74,
|
||||||
|
serialized_end=129,
|
||||||
|
)
|
||||||
|
|
||||||
|
_HEADERINFO.fields_by_name['video'].message_type = _VIDEO
|
||||||
|
_HEADER.fields_by_name['info'].message_type = _HEADERINFO
|
||||||
|
DESCRIPTOR.message_types_by_name['Video'] = _VIDEO
|
||||||
|
DESCRIPTOR.message_types_by_name['HeaderInfo'] = _HEADERINFO
|
||||||
|
DESCRIPTOR.message_types_by_name['Header'] = _HEADER
|
||||||
|
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||||
|
|
||||||
|
Video = _reflection.GeneratedProtocolMessageType('Video', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _VIDEO,
|
||||||
|
'__module__' : 'header_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:Video)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(Video)
|
||||||
|
|
||||||
|
HeaderInfo = _reflection.GeneratedProtocolMessageType('HeaderInfo', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _HEADERINFO,
|
||||||
|
'__module__' : 'header_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:HeaderInfo)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(HeaderInfo)
|
||||||
|
|
||||||
|
Header = _reflection.GeneratedProtocolMessageType('Header', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _HEADER,
|
||||||
|
'__module__' : 'header_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:Header)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(Header)
|
||||||
|
|
||||||
|
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
381
pytchat/paramgen/pb/live_pb2.py
Normal file
381
pytchat/paramgen/pb/live_pb2.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# source: live.proto
|
||||||
|
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import message as _message
|
||||||
|
from google.protobuf import reflection as _reflection
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||||
|
name='live.proto',
|
||||||
|
package='live',
|
||||||
|
syntax='proto3',
|
||||||
|
serialized_options=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
serialized_pb=b'\n\nlive.proto\x12\x04live\"\x88\x01\n\x04\x42ody\x12\n\n\x02\x62\x31\x18\x01 \x01(\x05\x12\n\n\x02\x62\x32\x18\x02 \x01(\x05\x12\n\n\x02\x62\x33\x18\x03 \x01(\x05\x12\n\n\x02\x62\x34\x18\x04 \x01(\x05\x12\n\n\x02\x62\x37\x18\x07 \x01(\t\x12\n\n\x02\x62\x38\x18\x08 \x01(\x05\x12\n\n\x02\x62\x39\x18\t \x01(\t\x12\x12\n\ntimestamp2\x18\n \x01(\x03\x12\x0b\n\x03\x62\x31\x31\x18\x0b \x01(\x05\x12\x0b\n\x03\x62\x31\x35\x18\x0f \x01(\x05\"\x19\n\x08\x43hatType\x12\r\n\x05value\x18\x01 \x01(\x05\"\x16\n\x05STR19\x12\r\n\x05value\x18\x01 \x01(\x05\"\x8a\x02\n\x12\x43ontinuationEntity\x12\x0e\n\x06header\x18\x03 \x01(\t\x12\x12\n\ntimestamp1\x18\x05 \x01(\x03\x12\n\n\x02s6\x18\x06 \x01(\x05\x12\n\n\x02s7\x18\x07 \x01(\x05\x12\n\n\x02s8\x18\x08 \x01(\x05\x12\x18\n\x04\x62ody\x18\t \x01(\x0b\x32\n.live.Body\x12\x12\n\ntimestamp3\x18\n \x01(\x03\x12\x12\n\ntimestamp4\x18\x0b \x01(\x03\x12\x0b\n\x03s13\x18\r \x01(\x05\x12 \n\x08\x63hattype\x18\x10 \x01(\x0b\x32\x0e.live.ChatType\x12\x0b\n\x03s17\x18\x11 \x01(\x05\x12\x1a\n\x05str19\x18\x13 \x01(\x0b\x32\x0b.live.STR19\x12\x12\n\ntimestamp5\x18\x14 \x01(\x03\";\n\x0c\x43ontinuation\x12+\n\x06\x65ntity\x18\xfa\xc0\x89\x39 \x01(\x0b\x32\x18.live.ContinuationEntityb\x06proto3'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_BODY = _descriptor.Descriptor(
|
||||||
|
name='Body',
|
||||||
|
full_name='live.Body',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b1', full_name='live.Body.b1', index=0,
|
||||||
|
number=1, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b2', full_name='live.Body.b2', index=1,
|
||||||
|
number=2, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b3', full_name='live.Body.b3', index=2,
|
||||||
|
number=3, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b4', full_name='live.Body.b4', index=3,
|
||||||
|
number=4, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b7', full_name='live.Body.b7', index=4,
|
||||||
|
number=7, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b8', full_name='live.Body.b8', index=5,
|
||||||
|
number=8, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b9', full_name='live.Body.b9', index=6,
|
||||||
|
number=9, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp2', full_name='live.Body.timestamp2', index=7,
|
||||||
|
number=10, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b11', full_name='live.Body.b11', index=8,
|
||||||
|
number=11, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='b15', full_name='live.Body.b15', index=9,
|
||||||
|
number=15, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=21,
|
||||||
|
serialized_end=157,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CHATTYPE = _descriptor.Descriptor(
|
||||||
|
name='ChatType',
|
||||||
|
full_name='live.ChatType',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='value', full_name='live.ChatType.value', index=0,
|
||||||
|
number=1, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=159,
|
||||||
|
serialized_end=184,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_STR19 = _descriptor.Descriptor(
|
||||||
|
name='STR19',
|
||||||
|
full_name='live.STR19',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='value', full_name='live.STR19.value', index=0,
|
||||||
|
number=1, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=186,
|
||||||
|
serialized_end=208,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTINUATIONENTITY = _descriptor.Descriptor(
|
||||||
|
name='ContinuationEntity',
|
||||||
|
full_name='live.ContinuationEntity',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='header', full_name='live.ContinuationEntity.header', index=0,
|
||||||
|
number=3, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp1', full_name='live.ContinuationEntity.timestamp1', index=1,
|
||||||
|
number=5, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s6', full_name='live.ContinuationEntity.s6', index=2,
|
||||||
|
number=6, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s7', full_name='live.ContinuationEntity.s7', index=3,
|
||||||
|
number=7, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s8', full_name='live.ContinuationEntity.s8', index=4,
|
||||||
|
number=8, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='body', full_name='live.ContinuationEntity.body', index=5,
|
||||||
|
number=9, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp3', full_name='live.ContinuationEntity.timestamp3', index=6,
|
||||||
|
number=10, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp4', full_name='live.ContinuationEntity.timestamp4', index=7,
|
||||||
|
number=11, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s13', full_name='live.ContinuationEntity.s13', index=8,
|
||||||
|
number=13, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='chattype', full_name='live.ContinuationEntity.chattype', index=9,
|
||||||
|
number=16, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s17', full_name='live.ContinuationEntity.s17', index=10,
|
||||||
|
number=17, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='str19', full_name='live.ContinuationEntity.str19', index=11,
|
||||||
|
number=19, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp5', full_name='live.ContinuationEntity.timestamp5', index=12,
|
||||||
|
number=20, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=211,
|
||||||
|
serialized_end=477,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTINUATION = _descriptor.Descriptor(
|
||||||
|
name='Continuation',
|
||||||
|
full_name='live.Continuation',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='entity', full_name='live.Continuation.entity', index=0,
|
||||||
|
number=119693434, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=479,
|
||||||
|
serialized_end=538,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CONTINUATIONENTITY.fields_by_name['body'].message_type = _BODY
|
||||||
|
_CONTINUATIONENTITY.fields_by_name['chattype'].message_type = _CHATTYPE
|
||||||
|
_CONTINUATIONENTITY.fields_by_name['str19'].message_type = _STR19
|
||||||
|
_CONTINUATION.fields_by_name['entity'].message_type = _CONTINUATIONENTITY
|
||||||
|
DESCRIPTOR.message_types_by_name['Body'] = _BODY
|
||||||
|
DESCRIPTOR.message_types_by_name['ChatType'] = _CHATTYPE
|
||||||
|
DESCRIPTOR.message_types_by_name['STR19'] = _STR19
|
||||||
|
DESCRIPTOR.message_types_by_name['ContinuationEntity'] = _CONTINUATIONENTITY
|
||||||
|
DESCRIPTOR.message_types_by_name['Continuation'] = _CONTINUATION
|
||||||
|
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||||
|
|
||||||
|
Body = _reflection.GeneratedProtocolMessageType('Body', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _BODY,
|
||||||
|
'__module__' : 'live_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:live.Body)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(Body)
|
||||||
|
|
||||||
|
ChatType = _reflection.GeneratedProtocolMessageType('ChatType', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CHATTYPE,
|
||||||
|
'__module__' : 'live_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:live.ChatType)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(ChatType)
|
||||||
|
|
||||||
|
STR19 = _reflection.GeneratedProtocolMessageType('STR19', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _STR19,
|
||||||
|
'__module__' : 'live_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:live.STR19)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(STR19)
|
||||||
|
|
||||||
|
ContinuationEntity = _reflection.GeneratedProtocolMessageType('ContinuationEntity', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CONTINUATIONENTITY,
|
||||||
|
'__module__' : 'live_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:live.ContinuationEntity)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(ContinuationEntity)
|
||||||
|
|
||||||
|
Continuation = _reflection.GeneratedProtocolMessageType('Continuation', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CONTINUATION,
|
||||||
|
'__module__' : 'live_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:live.Continuation)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(Continuation)
|
||||||
|
|
||||||
|
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
215
pytchat/paramgen/pb/replay_pb2.py
Normal file
215
pytchat/paramgen/pb/replay_pb2.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# source: replay.proto
|
||||||
|
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import message as _message
|
||||||
|
from google.protobuf import reflection as _reflection
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||||
|
name='replay.proto',
|
||||||
|
package='replay',
|
||||||
|
syntax='proto3',
|
||||||
|
serialized_options=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
serialized_pb=b'\n\x0creplay.proto\x12\x06replay\"\x19\n\x08\x43hatType\x12\r\n\x05value\x18\x01 \x01(\x05\"\xb2\x01\n\x12\x43ontinuationEntity\x12\x0e\n\x06header\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\n\n\x02s6\x18\x06 \x01(\x05\x12\n\n\x02s7\x18\x07 \x01(\x05\x12\n\n\x02s8\x18\x08 \x01(\x05\x12\n\n\x02s9\x18\t \x01(\x05\x12\x0b\n\x03s10\x18\n \x01(\t\x12\x0b\n\x03s12\x18\x0c \x01(\x05\x12\"\n\x08\x63hattype\x18\x0e \x01(\x0b\x32\x10.replay.ChatType\x12\x0b\n\x03s15\x18\x0f \x01(\x05\"=\n\x0c\x43ontinuation\x12-\n\x06\x65ntity\x18\xd4\x83\xb6J \x01(\x0b\x32\x1a.replay.ContinuationEntityb\x06proto3'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_CHATTYPE = _descriptor.Descriptor(
|
||||||
|
name='ChatType',
|
||||||
|
full_name='replay.ChatType',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='value', full_name='replay.ChatType.value', index=0,
|
||||||
|
number=1, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=24,
|
||||||
|
serialized_end=49,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTINUATIONENTITY = _descriptor.Descriptor(
|
||||||
|
name='ContinuationEntity',
|
||||||
|
full_name='replay.ContinuationEntity',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='header', full_name='replay.ContinuationEntity.header', index=0,
|
||||||
|
number=3, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='timestamp', full_name='replay.ContinuationEntity.timestamp', index=1,
|
||||||
|
number=5, type=3, cpp_type=2, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s6', full_name='replay.ContinuationEntity.s6', index=2,
|
||||||
|
number=6, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s7', full_name='replay.ContinuationEntity.s7', index=3,
|
||||||
|
number=7, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s8', full_name='replay.ContinuationEntity.s8', index=4,
|
||||||
|
number=8, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s9', full_name='replay.ContinuationEntity.s9', index=5,
|
||||||
|
number=9, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s10', full_name='replay.ContinuationEntity.s10', index=6,
|
||||||
|
number=10, type=9, cpp_type=9, label=1,
|
||||||
|
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s12', full_name='replay.ContinuationEntity.s12', index=7,
|
||||||
|
number=12, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='chattype', full_name='replay.ContinuationEntity.chattype', index=8,
|
||||||
|
number=14, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='s15', full_name='replay.ContinuationEntity.s15', index=9,
|
||||||
|
number=15, type=5, cpp_type=1, label=1,
|
||||||
|
has_default_value=False, default_value=0,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=52,
|
||||||
|
serialized_end=230,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTINUATION = _descriptor.Descriptor(
|
||||||
|
name='Continuation',
|
||||||
|
full_name='replay.Continuation',
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name='entity', full_name='replay.Continuation.entity', index=0,
|
||||||
|
number=156074452, type=11, cpp_type=10, label=1,
|
||||||
|
has_default_value=False, default_value=None,
|
||||||
|
message_type=None, enum_type=None, containing_type=None,
|
||||||
|
is_extension=False, extension_scope=None,
|
||||||
|
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||||
|
],
|
||||||
|
extensions=[
|
||||||
|
],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[
|
||||||
|
],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax='proto3',
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[
|
||||||
|
],
|
||||||
|
serialized_start=232,
|
||||||
|
serialized_end=293,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CONTINUATIONENTITY.fields_by_name['chattype'].message_type = _CHATTYPE
|
||||||
|
_CONTINUATION.fields_by_name['entity'].message_type = _CONTINUATIONENTITY
|
||||||
|
DESCRIPTOR.message_types_by_name['ChatType'] = _CHATTYPE
|
||||||
|
DESCRIPTOR.message_types_by_name['ContinuationEntity'] = _CONTINUATIONENTITY
|
||||||
|
DESCRIPTOR.message_types_by_name['Continuation'] = _CONTINUATION
|
||||||
|
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||||
|
|
||||||
|
ChatType = _reflection.GeneratedProtocolMessageType('ChatType', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CHATTYPE,
|
||||||
|
'__module__' : 'replay_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:replay.ChatType)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(ChatType)
|
||||||
|
|
||||||
|
ContinuationEntity = _reflection.GeneratedProtocolMessageType('ContinuationEntity', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CONTINUATIONENTITY,
|
||||||
|
'__module__' : 'replay_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:replay.ContinuationEntity)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(ContinuationEntity)
|
||||||
|
|
||||||
|
Continuation = _reflection.GeneratedProtocolMessageType('Continuation', (_message.Message,), {
|
||||||
|
'DESCRIPTOR' : _CONTINUATION,
|
||||||
|
'__module__' : 'replay_pb2'
|
||||||
|
# @@protoc_insertion_point(class_scope:replay.Continuation)
|
||||||
|
})
|
||||||
|
_sym_db.RegisterMessage(Continuation)
|
||||||
|
|
||||||
|
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
14
pytchat/paramgen/proto/header.proto
Normal file
14
pytchat/paramgen/proto/header.proto
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
message Video {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HeaderInfo {
|
||||||
|
Video video = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Header {
|
||||||
|
HeaderInfo info = 1;
|
||||||
|
int32 terminator = 4;
|
||||||
|
}
|
||||||
45
pytchat/paramgen/proto/live.proto
Normal file
45
pytchat/paramgen/proto/live.proto
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package live;
|
||||||
|
|
||||||
|
message Body {
|
||||||
|
int32 b1 = 1;
|
||||||
|
int32 b2 = 2;
|
||||||
|
int32 b3 = 3;
|
||||||
|
int32 b4 = 4;
|
||||||
|
string b7 = 7;
|
||||||
|
int32 b8 = 8;
|
||||||
|
string b9 = 9;
|
||||||
|
int64 timestamp2 = 10;
|
||||||
|
int32 b11 = 11;
|
||||||
|
int32 b15 = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChatType {
|
||||||
|
int32 value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message STR19 {
|
||||||
|
int32 value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContinuationEntity {
|
||||||
|
string header = 3;
|
||||||
|
int64 timestamp1 = 5;
|
||||||
|
int32 s6 = 6;
|
||||||
|
int32 s7 = 7;
|
||||||
|
int32 s8 = 8;
|
||||||
|
Body body = 9;
|
||||||
|
int64 timestamp3 = 10;
|
||||||
|
int64 timestamp4 = 11;
|
||||||
|
int32 s13 = 13;
|
||||||
|
ChatType chattype = 16;
|
||||||
|
int32 s17 = 17;
|
||||||
|
STR19 str19 = 19;
|
||||||
|
int64 timestamp5 = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Continuation {
|
||||||
|
ContinuationEntity entity = 119693434;
|
||||||
|
}
|
||||||
|
|
||||||
24
pytchat/paramgen/proto/replay.proto
Normal file
24
pytchat/paramgen/proto/replay.proto
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package replay;
|
||||||
|
|
||||||
|
message ChatType {
|
||||||
|
int32 value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContinuationEntity {
|
||||||
|
string header = 3;
|
||||||
|
int64 timestamp = 5;
|
||||||
|
int32 s6 = 6;
|
||||||
|
int32 s7 = 7;
|
||||||
|
int32 s8 = 8;
|
||||||
|
int32 s9 = 9;
|
||||||
|
string s10 = 10;
|
||||||
|
int32 s12 = 12;
|
||||||
|
ChatType chattype = 14;
|
||||||
|
int32 s15 = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Continuation {
|
||||||
|
ContinuationEntity entity = 156074452;
|
||||||
|
}
|
||||||
@@ -1,65 +1,105 @@
|
|||||||
"""
|
"""
|
||||||
pytchat.parser.live
|
pytchat.parser.live
|
||||||
~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
This module is parser of live chat JSON.
|
Parser of live chat JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from .. import exceptions
|
||||||
from .. import config
|
|
||||||
from .. exceptions import (
|
|
||||||
ResponseContextError,
|
|
||||||
NoContentsException,
|
|
||||||
NoContinuationsException )
|
|
||||||
|
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Parser:
|
class Parser:
|
||||||
def parse(self, jsn):
|
|
||||||
"""
|
|
||||||
このparse関数はLiveChat._listen() 関数から定期的に呼び出される。
|
|
||||||
引数jsnはYoutubeから取得したチャットデータの生JSONであり、
|
|
||||||
このparse関数によって与えられたJSONを以下に分割して返す。
|
|
||||||
+ timeout (次のチャットデータ取得までのインターバル)
|
|
||||||
+ chat data(チャットデータ本体)
|
|
||||||
+ continuation (次のチャットデータ取得に必要となるパラメータ).
|
|
||||||
|
|
||||||
|
__slots__ = ['is_replay']
|
||||||
|
|
||||||
|
def __init__(self, is_replay):
|
||||||
|
self.is_replay = is_replay
|
||||||
|
|
||||||
|
def get_contents(self, jsn):
|
||||||
|
if jsn is None:
|
||||||
|
raise exceptions.IllegalFunctionCall('Called with none JSON object.')
|
||||||
|
if jsn['response']['responseContext'].get('errors'):
|
||||||
|
raise exceptions.ResponseContextError(
|
||||||
|
'The video_id would be wrong, or video is deleted or private.')
|
||||||
|
contents = jsn['response'].get('continuationContents')
|
||||||
|
return contents
|
||||||
|
|
||||||
|
def parse(self, contents):
|
||||||
|
"""
|
||||||
Parameter
|
Parameter
|
||||||
----------
|
----------
|
||||||
+ jsn : dict
|
+ contents : dict
|
||||||
+ Youtubeから取得したチャットデータのJSONオブジェクト。
|
+ JSON of chat data from YouTube.
|
||||||
(pythonの辞書形式に変換済みの状態で渡される)
|
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
tuple:
|
||||||
+ metadata : dict
|
+ metadata : dict
|
||||||
+ チャットデータに付随するメタデータ。timeout、 動画ID、continuationパラメータで構成される。
|
+ timeout
|
||||||
+ chatdata : list[dict]
|
+ video_id
|
||||||
+ チャットデータ本体のリスト。
|
+ continuation
|
||||||
|
+ chatdata : List[dict]
|
||||||
"""
|
"""
|
||||||
if jsn is None:
|
|
||||||
return {'timeoutMs':0,'continuation':None},[]
|
|
||||||
if jsn['response']['responseContext'].get('errors'):
|
|
||||||
raise ResponseContextError('動画に接続できません。'
|
|
||||||
'動画IDが間違っているか、動画が削除/非公開の可能性があります。')
|
|
||||||
contents=jsn['response'].get('continuationContents')
|
|
||||||
#配信が終了した場合、もしくはチャットデータが取得できない場合
|
|
||||||
if contents is None:
|
if contents is None:
|
||||||
raise NoContentsException('チャットデータを取得できませんでした。')
|
'''Broadcasting end or cannot fetch chat stream'''
|
||||||
|
raise exceptions.NoContents('Chat data stream is empty.')
|
||||||
|
|
||||||
cont = contents['liveChatContinuation']['continuations'][0]
|
cont = contents['liveChatContinuation']['continuations'][0]
|
||||||
if cont is None:
|
if cont is None:
|
||||||
raise NoContinuationsException('Continuationがありません。')
|
raise exceptions.NoContinuation('No Continuation')
|
||||||
metadata = (cont.get('invalidationContinuationData') or
|
metadata = (cont.get('invalidationContinuationData')
|
||||||
cont.get('timedContinuationData') or
|
or cont.get('timedContinuationData')
|
||||||
cont.get('reloadContinuationData')
|
or cont.get('reloadContinuationData')
|
||||||
|
or cont.get('liveChatReplayContinuationData')
|
||||||
)
|
)
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
|
if cont.get("playerSeekContinuationData"):
|
||||||
|
raise exceptions.ChatDataFinished('Finished chat data')
|
||||||
unknown = list(cont.keys())[0]
|
unknown = list(cont.keys())[0]
|
||||||
if unknown:
|
if unknown:
|
||||||
logger.debug(f"Received unknown continuation type:{unknown}")
|
raise exceptions.ReceivedUnknownContinuation(
|
||||||
metadata = cont.get(unknown)
|
f"Received unknown continuation type:{unknown}")
|
||||||
metadata.setdefault('timeoutMs', 10000)
|
else:
|
||||||
chatdata = contents['liveChatContinuation'].get('actions')
|
raise exceptions.FailedExtractContinuation('Cannot extract continuation data')
|
||||||
|
return self._create_data(metadata, contents)
|
||||||
|
|
||||||
|
def reload_continuation(self, contents):
|
||||||
|
"""
|
||||||
|
When `seektime == 0` or seektime is abbreviated ,
|
||||||
|
check if fetched chat json has no chat data.
|
||||||
|
If so, try to fetch playerSeekContinuationData.
|
||||||
|
This function must be run only first fetching.
|
||||||
|
"""
|
||||||
|
if contents is None:
|
||||||
|
'''Broadcasting end or cannot fetch chat stream'''
|
||||||
|
raise exceptions.NoContents('Chat data stream is empty.')
|
||||||
|
cont = contents['liveChatContinuation']['continuations'][0]
|
||||||
|
if cont.get("liveChatReplayContinuationData"):
|
||||||
|
# chat data exist.
|
||||||
|
return None
|
||||||
|
# chat data do not exist, get playerSeekContinuationData.
|
||||||
|
init_cont = cont.get("playerSeekContinuationData")
|
||||||
|
if init_cont:
|
||||||
|
return init_cont.get("continuation")
|
||||||
|
raise exceptions.ChatDataFinished('Finished chat data')
|
||||||
|
|
||||||
|
def _create_data(self, metadata, contents):
|
||||||
|
actions = contents['liveChatContinuation'].get('actions')
|
||||||
|
if self.is_replay:
|
||||||
|
interval = self._get_interval(actions)
|
||||||
|
metadata.setdefault("timeoutMs", interval)
|
||||||
|
"""Archived chat has different structures than live chat,
|
||||||
|
so make it the same format."""
|
||||||
|
chatdata = [action["replayChatItemAction"]["actions"][0]
|
||||||
|
for action in actions]
|
||||||
|
else:
|
||||||
|
metadata.setdefault('timeoutMs', 10000)
|
||||||
|
chatdata = actions
|
||||||
return metadata, chatdata
|
return metadata, chatdata
|
||||||
|
|
||||||
|
def _get_interval(self, actions: list):
|
||||||
|
if actions is None:
|
||||||
|
return 0
|
||||||
|
start = int(actions[0]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||||
|
last = int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||||
|
return (last - start)
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import json
|
|
||||||
from .. import config
|
|
||||||
from .. exceptions import (
|
|
||||||
ResponseContextError,
|
|
||||||
NoContentsException,
|
|
||||||
NoContinuationsException )
|
|
||||||
|
|
||||||
|
|
||||||
logger = config.logger(__name__)
|
|
||||||
|
|
||||||
class Parser:
|
|
||||||
def parse(self, jsn):
|
|
||||||
"""
|
|
||||||
このparse関数はReplayChat._listen() 関数から定期的に呼び出される。
|
|
||||||
引数jsnはYoutubeから取得したアーカイブ済みチャットデータの生JSONであり、
|
|
||||||
このparse関数によって与えられたJSONを以下に分割して返す。
|
|
||||||
+ timeout (次のチャットデータ取得までのインターバル)
|
|
||||||
+ chat data(チャットデータ本体)
|
|
||||||
+ continuation (次のチャットデータ取得に必要となるパラメータ).
|
|
||||||
|
|
||||||
ライブ配信のチャットとアーカイブ済み動画のチャットは構造が若干異なっているが、
|
|
||||||
ライブチャットと同じデータ形式に変換することにより、
|
|
||||||
同じprocessorでライブとリプレイどちらでも利用できるようにしている。
|
|
||||||
|
|
||||||
Parameter
|
|
||||||
----------
|
|
||||||
+ jsn : dict
|
|
||||||
+ Youtubeから取得したチャットデータのJSONオブジェクト。
|
|
||||||
(pythonの辞書形式に変換済みの状態で渡される)
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
+ metadata : dict
|
|
||||||
+ チャットデータに付随するメタデータ。timeout、 動画ID、continuationパラメータで構成される。
|
|
||||||
+ chatdata : list[dict]
|
|
||||||
+ チャットデータ本体のリスト。
|
|
||||||
"""
|
|
||||||
if jsn is None:
|
|
||||||
return {'timeoutMs':0,'continuation':None},[]
|
|
||||||
if jsn['response']['responseContext'].get('errors'):
|
|
||||||
raise ResponseContextError('動画に接続できません。'
|
|
||||||
'動画IDが間違っているか、動画が削除/非公開の可能性があります。')
|
|
||||||
contents=jsn['response'].get('continuationContents')
|
|
||||||
#配信が終了した場合、もしくはチャットデータが取得できない場合
|
|
||||||
if contents is None:
|
|
||||||
raise NoContentsException('チャットデータを取得できませんでした。')
|
|
||||||
|
|
||||||
cont = contents['liveChatContinuation']['continuations'][0]
|
|
||||||
if cont is None:
|
|
||||||
raise NoContinuationsException('Continuationがありません。')
|
|
||||||
metadata = cont.get('liveChatReplayContinuationData')
|
|
||||||
if metadata is None:
|
|
||||||
unknown = list(cont.keys())[0]
|
|
||||||
if unknown != "playerSeekContinuationData":
|
|
||||||
logger.debug(f"Received unknown continuation type:{unknown}")
|
|
||||||
metadata = cont.get(unknown)
|
|
||||||
actions = contents['liveChatContinuation'].get('actions')
|
|
||||||
if actions is None:
|
|
||||||
#後続のチャットデータなし
|
|
||||||
return {"continuation":None,"timeout":0,"chatdata":[]}
|
|
||||||
interval = self.get_interval(actions)
|
|
||||||
metadata.setdefault("timeoutMs",interval)
|
|
||||||
"""アーカイブ済みチャットはライブチャットと構造が異なっているため、以下の行により
|
|
||||||
ライブチャットと同じ形式にそろえる"""
|
|
||||||
chatdata = [action["replayChatItemAction"]["actions"][0] for action in actions]
|
|
||||||
return metadata, chatdata
|
|
||||||
|
|
||||||
def get_interval(self, actions: list):
|
|
||||||
if actions is None:
|
|
||||||
return 0
|
|
||||||
start = int(actions[0]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
|
||||||
last = int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
|
||||||
return (last - start)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
class ChatProcessor:
|
class ChatProcessor:
|
||||||
'''
|
'''
|
||||||
Listenerからチャットデータ(actions)を受け取り
|
Abstract class that processes chat data.
|
||||||
チャットデータを加工するクラスの抽象クラス
|
Receive chat data (actions) from Listener.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def process(self, chat_components: list):
|
def process(self, chat_components: list):
|
||||||
'''
|
'''
|
||||||
チャットデータの加工を表すインターフェース。
|
Interface that represents processing of chat data.
|
||||||
LiveChatオブジェクトから呼び出される。
|
Called from LiveChat object.
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
----------
|
----------
|
||||||
chat_components: List[component]
|
chat_components: List[component]
|
||||||
component : dict {
|
component : dict {
|
||||||
"video_id" : str
|
"video_id" : str
|
||||||
動画ID
|
|
||||||
"timeout" : int
|
"timeout" : int
|
||||||
次のチャットの再読み込みまでの時間(秒)
|
Time to fetch next chat (seconds)
|
||||||
"chatdata" : List[dict]
|
"chatdata" : List[dict]
|
||||||
チャットデータのリスト
|
List of chat data.
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def finalize(self, *args, **kwargs):
|
||||||
|
'''
|
||||||
|
Interface for finalizing the process.
|
||||||
|
Called when chat fetching finished.
|
||||||
|
'''
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .chat_processor import ChatProcessor
|
from .chat_processor import ChatProcessor
|
||||||
|
|
||||||
|
|
||||||
class Combinator(ChatProcessor):
|
class Combinator(ChatProcessor):
|
||||||
'''
|
'''
|
||||||
Combinator combines multiple chat processors.
|
Combinator combines multiple chat processors.
|
||||||
@@ -7,12 +8,12 @@ class Combinator(ChatProcessor):
|
|||||||
|
|
||||||
For example:
|
For example:
|
||||||
[constructor]
|
[constructor]
|
||||||
chat = LiveChat("video_id", processor = ( Processor1(), Processor2(), Processor3() )
|
chat = LiveChat("video_id", processor = ( Processor1(), Processor2(), Processor3() ) )
|
||||||
|
|
||||||
[receive return values]
|
[receive return values]
|
||||||
ret1, ret2, ret3 = chat.get()
|
ret1, ret2, ret3 = chat.get()
|
||||||
|
|
||||||
The return values are tuple of processed chat data,
|
The return values are tuple of processed chat data,
|
||||||
the order of return depends on parameter order.
|
the order of return depends on parameter order.
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
@@ -33,7 +34,5 @@ class Combinator(ChatProcessor):
|
|||||||
-------
|
-------
|
||||||
Tuple of chat data processed by each chat processor.
|
Tuple of chat data processed by each chat processor.
|
||||||
'''
|
'''
|
||||||
return tuple([processor.process(chat_components)
|
return tuple(processor.process(chat_components)
|
||||||
for processor in self.processors])
|
for processor in self.processors)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import datetime
|
|
||||||
import time
|
|
||||||
from .renderer.textmessage import LiveChatTextMessageRenderer
|
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"] = ""
|
||||||
@@ -23,20 +23,24 @@ class CompatibleProcessor(ChatProcessor):
|
|||||||
for chat_component in chat_components:
|
for chat_component in chat_components:
|
||||||
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,35 +51,37 @@ 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 is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
return rd
|
return rd
|
||||||
|
|
||||||
def get_renderer(self, item):
|
def get_renderer(self, item):
|
||||||
if item.get("liveChatTextMessageRenderer"):
|
if item.get("liveChatTextMessageRenderer"):
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,67 @@
|
|||||||
import datetime, pytz
|
import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
class BaseRenderer:
|
class BaseRenderer:
|
||||||
def __init__(self, item, chattype):
|
def __init__(self, item, chattype):
|
||||||
self.renderer = list(item.values())[0]
|
self.renderer = list(item.values())[0]
|
||||||
self.chattype = chattype
|
self.chattype = chattype
|
||||||
|
|
||||||
|
|
||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
|
|
||||||
message = self.get_message(self.renderer)
|
message = self.get_message(self.renderer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type" : self.chattype,
|
"type": self.chattype,
|
||||||
"liveChatId" : "",
|
"liveChatId": "",
|
||||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||||
"hasDisplayContent" : True,
|
"hasDisplayContent": True,
|
||||||
"displayMessage" : message,
|
"displayMessage": message,
|
||||||
"textMessageDetails": {
|
"textMessageDetails": {
|
||||||
"messageText" : message
|
"messageText": message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_authordetails(self):
|
def get_authordetails(self):
|
||||||
authorExternalChannelId = self.renderer.get("authorExternalChannelId")
|
authorExternalChannelId = self.renderer.get("authorExternalChannelId")
|
||||||
#parse subscriber type
|
# parse subscriber type
|
||||||
isVerified, isChatOwner, isChatSponsor, isChatModerator = (
|
isVerified, isChatOwner, isChatSponsor, isChatModerator = (
|
||||||
self.get_badges(self.renderer)
|
self.get_badges(self.renderer)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"channelId" : authorExternalChannelId,
|
"channelId": authorExternalChannelId,
|
||||||
"channelUrl" : "http://www.youtube.com/channel/"+authorExternalChannelId,
|
"channelUrl": "http://www.youtube.com/channel/" + authorExternalChannelId,
|
||||||
"displayName" : self.renderer["authorName"]["simpleText"],
|
"displayName": self.renderer["authorName"]["simpleText"],
|
||||||
"profileImageUrl" : self.renderer["authorPhoto"]["thumbnails"][1]["url"] ,
|
"profileImageUrl": self.renderer["authorPhoto"]["thumbnails"][1]["url"],
|
||||||
"isVerified" : isVerified,
|
"isVerified": isVerified,
|
||||||
"isChatOwner" : isChatOwner,
|
"isChatOwner": isChatOwner,
|
||||||
"isChatSponsor" : isChatSponsor,
|
"isChatSponsor": isChatSponsor,
|
||||||
"isChatModerator" : isChatModerator
|
"isChatModerator": isChatModerator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_message(self, renderer):
|
||||||
def get_message(self,renderer):
|
|
||||||
message = ''
|
message = ''
|
||||||
if renderer.get("message"):
|
if renderer.get("message"):
|
||||||
runs=renderer["message"].get("runs")
|
runs = renderer["message"].get("runs")
|
||||||
if runs:
|
if runs:
|
||||||
for r in runs:
|
for r in runs:
|
||||||
if r:
|
if r:
|
||||||
if r.get('emoji'):
|
if r.get('emoji'):
|
||||||
message += r['emoji'].get('shortcuts',[''])[0]
|
message += r['emoji'].get('shortcuts', [''])[0]
|
||||||
else:
|
else:
|
||||||
message += r.get('text','')
|
message += r.get('text', '')
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def get_badges(self,renderer):
|
def get_badges(self, renderer):
|
||||||
isVerified = False
|
isVerified = False
|
||||||
isChatOwner = False
|
isChatOwner = False
|
||||||
isChatSponsor = False
|
isChatSponsor = False
|
||||||
isChatModerator = False
|
isChatModerator = False
|
||||||
badges=renderer.get("authorBadges")
|
badges = renderer.get("authorBadges")
|
||||||
if badges:
|
if badges:
|
||||||
for badge in badges:
|
for badge in badges:
|
||||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
||||||
if author_type == '確認済み':
|
if author_type == '確認済み':
|
||||||
isVerified = True
|
isVerified = True
|
||||||
if author_type == '所有者':
|
if author_type == '所有者':
|
||||||
@@ -72,12 +71,11 @@ class BaseRenderer:
|
|||||||
if author_type == 'モデレーター':
|
if author_type == 'モデレーター':
|
||||||
isChatModerator = True
|
isChatModerator = True
|
||||||
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
return self.renderer.get('id')
|
return self.renderer.get('id')
|
||||||
|
|
||||||
def get_publishedat(self,timestamp):
|
def get_publishedat(self, timestamp):
|
||||||
dt = datetime.datetime.fromtimestamp(int(timestamp)/1000000)
|
dt = datetime.datetime.fromtimestamp(int(timestamp) / 1000000)
|
||||||
return dt.astimezone(pytz.utc).isoformat(
|
return dt.astimezone(pytz.utc).isoformat(
|
||||||
timespec='milliseconds').replace('+00:00','Z')
|
timespec='milliseconds').replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ symbols = {
|
|||||||
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
||||||
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
||||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"}
|
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||||
}
|
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "newSponsorEvent")
|
super().__init__(item, "newSponsorEvent")
|
||||||
@@ -8,36 +10,33 @@ class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
|||||||
message = self.get_message(self.renderer)
|
message = self.get_message(self.renderer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type" : self.chattype,
|
"type": self.chattype,
|
||||||
"liveChatId" : "",
|
"liveChatId": "",
|
||||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||||
"hasDisplayContent" : True,
|
"hasDisplayContent": True,
|
||||||
"displayMessage" : message,
|
"displayMessage": message,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_authordetails(self):
|
def get_authordetails(self):
|
||||||
authorExternalChannelId = self.renderer.get("authorExternalChannelId")
|
authorExternalChannelId = self.renderer.get("authorExternalChannelId")
|
||||||
#parse subscriber type
|
# parse subscriber type
|
||||||
isVerified, isChatOwner, _, isChatModerator = (
|
isVerified, isChatOwner, _, isChatModerator = (
|
||||||
self.get_badges(self.renderer)
|
self.get_badges(self.renderer)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"channelId" : authorExternalChannelId,
|
"channelId": authorExternalChannelId,
|
||||||
"channelUrl" : "http://www.youtube.com/channel/"+authorExternalChannelId,
|
"channelUrl": "http://www.youtube.com/channel/" + authorExternalChannelId,
|
||||||
"displayName" : self.renderer["authorName"]["simpleText"],
|
"displayName": self.renderer["authorName"]["simpleText"],
|
||||||
"profileImageUrl" : self.renderer["authorPhoto"]["thumbnails"][1]["url"] ,
|
"profileImageUrl": self.renderer["authorPhoto"]["thumbnails"][1]["url"],
|
||||||
"isVerified" : isVerified,
|
"isVerified": isVerified,
|
||||||
"isChatOwner" : isChatOwner,
|
"isChatOwner": isChatOwner,
|
||||||
"isChatSponsor" : True,
|
"isChatSponsor": True,
|
||||||
"isChatModerator" : isChatModerator
|
"isChatModerator": isChatModerator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_message(self, renderer):
|
||||||
def get_message(self,renderer):
|
|
||||||
message = (renderer["eventText"]["runs"][0]["text"]
|
message = (renderer["eventText"]["runs"][0]["text"]
|
||||||
)+' / '+(renderer["detailText"]["simpleText"])
|
) + ' / ' + (renderer["detailText"]["simpleText"])
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
pytchat/processors/compatible/renderer/membership.py
Normal file
40
pytchat/processors/compatible/renderer/membership.py
Normal 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]
|
||||||
@@ -3,6 +3,7 @@ from . import currency
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||||
|
|
||||||
|
|
||||||
class LiveChatPaidMessageRenderer(BaseRenderer):
|
class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "superChatEvent")
|
super().__init__(item, "superChatEvent")
|
||||||
@@ -10,32 +11,32 @@ class LiveChatPaidMessageRenderer(BaseRenderer):
|
|||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
authorName = self.renderer["authorName"]["simpleText"]
|
authorName = self.renderer["authorName"]["simpleText"]
|
||||||
message = self.get_message(self.renderer)
|
message = self.get_message(self.renderer)
|
||||||
amountDisplayString, symbol, amountMicros =(
|
amountDisplayString, symbol, amountMicros = (
|
||||||
self.get_amountdata(self.renderer)
|
self.get_amountdata(self.renderer)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"type" : self.chattype,
|
"type": self.chattype,
|
||||||
"liveChatId" : "",
|
"liveChatId": "",
|
||||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||||
"hasDisplayContent" : True,
|
"hasDisplayContent": True,
|
||||||
"displayMessage" : amountDisplayString+" from "+authorName+': \"'+ message+'\"',
|
"displayMessage": amountDisplayString + " from " + authorName + ': \"' + message + '\"',
|
||||||
"superChatDetails" : {
|
"superChatDetails": {
|
||||||
"amountMicros" : amountMicros,
|
"amountMicros": amountMicros,
|
||||||
"currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
"currency": currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||||
"amountDisplayString" : amountDisplayString,
|
"amountDisplayString": amountDisplayString,
|
||||||
"tier" : 0,
|
"tier": 0,
|
||||||
"backgroundColor" : self.renderer.get("bodyBackgroundColor", 0)
|
"backgroundColor": self.renderer.get("bodyBackgroundColor", 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_amountdata(self,renderer):
|
def get_amountdata(self, renderer):
|
||||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||||
m = superchat_regex.search(amountDisplayString)
|
m = superchat_regex.search(amountDisplayString)
|
||||||
if m:
|
if m:
|
||||||
symbol = m.group(1)
|
symbol = m.group(1)
|
||||||
amountMicros = int(float(m.group(2).replace(',',''))*1000000)
|
amountMicros = int(float(m.group(2).replace(',', '')) * 1000000)
|
||||||
else:
|
else:
|
||||||
symbol = ""
|
symbol = ""
|
||||||
amountMicros = 0
|
amountMicros = 0
|
||||||
return amountDisplayString, symbol, amountMicros
|
return amountDisplayString, symbol, amountMicros
|
||||||
|
|||||||
@@ -3,46 +3,45 @@ from . import currency
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||||
|
|
||||||
|
|
||||||
class LiveChatPaidStickerRenderer(BaseRenderer):
|
class LiveChatPaidStickerRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "superStickerEvent")
|
super().__init__(item, "superStickerEvent")
|
||||||
|
|
||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
authorName = self.renderer["authorName"]["simpleText"]
|
authorName = self.renderer["authorName"]["simpleText"]
|
||||||
amountDisplayString, symbol, amountMicros =(
|
amountDisplayString, symbol, amountMicros = (
|
||||||
self.get_amountdata(self.renderer)
|
self.get_amountdata(self.renderer)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type" : self.chattype,
|
"type": self.chattype,
|
||||||
"liveChatId" : "",
|
"liveChatId": "",
|
||||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||||
"hasDisplayContent" : True,
|
"hasDisplayContent": True,
|
||||||
"displayMessage" : "Super Sticker " + amountDisplayString + " from "+authorName,
|
"displayMessage": "Super Sticker " + amountDisplayString + " from " + authorName,
|
||||||
"superStickerDetails" : {
|
"superStickerDetails": {
|
||||||
"superStickerMetaData" : {
|
"superStickerMetaData": {
|
||||||
"stickerId": "",
|
"stickerId": "",
|
||||||
"altText": "",
|
"altText": "",
|
||||||
"language": ""
|
"language": ""
|
||||||
},
|
},
|
||||||
"amountMicros" : amountMicros,
|
"amountMicros": amountMicros,
|
||||||
"currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
"currency": currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||||
"amountDisplayString" : amountDisplayString,
|
"amountDisplayString": amountDisplayString,
|
||||||
"tier" : 0,
|
"tier": 0,
|
||||||
"backgroundColor" : self.renderer.get("bodyBackgroundColor", 0)
|
"backgroundColor": self.renderer.get("bodyBackgroundColor", 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_amountdata(self,renderer):
|
def get_amountdata(self, renderer):
|
||||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||||
m = superchat_regex.search(amountDisplayString)
|
m = superchat_regex.search(amountDisplayString)
|
||||||
if m:
|
if m:
|
||||||
symbol = m.group(1)
|
symbol = m.group(1)
|
||||||
amountMicros = int(float(m.group(2).replace(',',''))*1000000)
|
amountMicros = int(float(m.group(2).replace(',', '')) * 1000000)
|
||||||
else:
|
else:
|
||||||
symbol = ""
|
symbol = ""
|
||||||
amountMicros = 0
|
amountMicros = 0
|
||||||
return amountDisplayString, symbol, amountMicros
|
return amountDisplayString, symbol, amountMicros
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "textMessageEvent")
|
super().__init__(item, "textMessageEvent")
|
||||||
|
|||||||
@@ -4,26 +4,30 @@ 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
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
if self.interval == 0:
|
if self.interval == 0:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
return
|
return
|
||||||
time.sleep(self.interval/len(self.items))
|
time.sleep(self.interval / len(self.items))
|
||||||
|
|
||||||
async def tick_async(self):
|
async def tick_async(self):
|
||||||
if self.interval == 0:
|
if self.interval == 0:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
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,46 +39,50 @@ 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:
|
||||||
if chatdata is None: continue
|
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 is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
renderer.get_snippet()
|
renderer.get_snippet()
|
||||||
renderer.get_authordetails()
|
renderer.get_authordetails()
|
||||||
except (KeyError,TypeError,AttributeError) 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
|
||||||
|
|
||||||
def _get_renderer(self, item):
|
def _get_renderer(self, item):
|
||||||
if item.get("liveChatTextMessageRenderer"):
|
if item.get("liveChatTextMessageRenderer"):
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class Author:
|
class Author:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BaseRenderer:
|
class BaseRenderer:
|
||||||
def __init__(self, item, chattype):
|
def __init__(self, item, chattype):
|
||||||
self.renderer = list(item.values())[0]
|
self.renderer = list(item.values())[0]
|
||||||
@@ -11,80 +14,81 @@ class BaseRenderer:
|
|||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
self.type = self.chattype
|
self.type = self.chattype
|
||||||
self.id = self.renderer.get('id')
|
self.id = self.renderer.get('id')
|
||||||
timestampUsec = int(self.renderer.get("timestampUsec",0))
|
timestampUsec = int(self.renderer.get("timestampUsec", 0))
|
||||||
self.timestamp = int(timestampUsec/1000)
|
self.timestamp = int(timestampUsec / 1000)
|
||||||
tst = self.renderer.get("timestampText")
|
tst = self.renderer.get("timestampText")
|
||||||
if tst:
|
if tst:
|
||||||
self.elapsedTime = tst.get("simpleText")
|
self.elapsedTime = tst.get("simpleText")
|
||||||
else:
|
else:
|
||||||
self.elapsedTime = ""
|
self.elapsedTime = ""
|
||||||
self.datetime = self.get_datetime(timestampUsec)
|
self.datetime = self.get_datetime(timestampUsec)
|
||||||
self.message ,self.messageEx = self.get_message(self.renderer)
|
self.message, self.messageEx = self.get_message(self.renderer)
|
||||||
self.id = self.renderer.get('id')
|
self.id = self.renderer.get('id')
|
||||||
self.amountValue= 0.0
|
self.amountValue = 0.0
|
||||||
self.amountString = ""
|
self.amountString = ""
|
||||||
self.currency= ""
|
self.currency = ""
|
||||||
self.bgColor = 0
|
self.bgColor = 0
|
||||||
|
|
||||||
def get_authordetails(self):
|
def get_authordetails(self):
|
||||||
self.author.badgeUrl = ""
|
self.author.badgeUrl = ""
|
||||||
(self.author.isVerified,
|
(self.author.isVerified,
|
||||||
self.author.isChatOwner,
|
self.author.isChatOwner,
|
||||||
self.author.isChatSponsor,
|
self.author.isChatSponsor,
|
||||||
self.author.isChatModerator) = (
|
self.author.isChatModerator) = (
|
||||||
self.get_badges(self.renderer)
|
self.get_badges(self.renderer)
|
||||||
)
|
)
|
||||||
self.author.channelId = self.renderer.get("authorExternalChannelId")
|
self.author.channelId = self.renderer.get("authorExternalChannelId")
|
||||||
self.author.channelUrl = "http://www.youtube.com/channel/"+self.author.channelId
|
self.author.channelUrl = "http://www.youtube.com/channel/" + self.author.channelId
|
||||||
self.author.name = self.renderer["authorName"]["simpleText"]
|
self.author.name = self.renderer["authorName"]["simpleText"]
|
||||||
self.author.imageUrl= self.renderer["authorPhoto"]["thumbnails"][1]["url"]
|
self.author.imageUrl = self.renderer["authorPhoto"]["thumbnails"][1]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_message(self, renderer):
|
||||||
def get_message(self,renderer):
|
|
||||||
message = ''
|
message = ''
|
||||||
message_ex = []
|
message_ex = []
|
||||||
if renderer.get("message"):
|
if renderer.get("message"):
|
||||||
runs=renderer["message"].get("runs")
|
runs = renderer["message"].get("runs")
|
||||||
if runs:
|
if runs:
|
||||||
for r in runs:
|
for r in runs:
|
||||||
if r:
|
if r:
|
||||||
if r.get('emoji'):
|
if r.get('emoji'):
|
||||||
message += r['emoji'].get('shortcuts',[''])[0]
|
message += r['emoji'].get('shortcuts', [''])[0]
|
||||||
message_ex.append(r['emoji']['image']['thumbnails'][1].get('url'))
|
message_ex.append({
|
||||||
|
'id': r['emoji'].get('emojiId').split('/')[-1],
|
||||||
|
'txt': r['emoji'].get('shortcuts', [''])[0],
|
||||||
|
'url': r['emoji']['image']['thumbnails'][0].get('url')
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
message += r.get('text','')
|
message += r.get('text', '')
|
||||||
message_ex.append(r.get('text',''))
|
message_ex.append(r.get('text', ''))
|
||||||
return message, message_ex
|
return message, message_ex
|
||||||
|
|
||||||
|
def get_badges(self, renderer):
|
||||||
|
self.author.type = ''
|
||||||
def get_badges(self,renderer):
|
|
||||||
isVerified = False
|
isVerified = False
|
||||||
isChatOwner = False
|
isChatOwner = False
|
||||||
isChatSponsor = False
|
isChatSponsor = False
|
||||||
isChatModerator = False
|
isChatModerator = False
|
||||||
badges=renderer.get("authorBadges")
|
badges = renderer.get("authorBadges")
|
||||||
if badges:
|
if badges:
|
||||||
for badge in badges:
|
for badge in badges:
|
||||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
if badge["liveChatAuthorBadgeRenderer"].get("icon"):
|
||||||
if author_type == '確認済み':
|
author_type = badge["liveChatAuthorBadgeRenderer"]["icon"]["iconType"]
|
||||||
isVerified = True
|
self.author.type = author_type
|
||||||
if author_type == '所有者':
|
if author_type == 'VERIFIED':
|
||||||
isChatOwner = True
|
isVerified = True
|
||||||
if 'メンバー' in author_type:
|
if author_type == 'OWNER':
|
||||||
|
isChatOwner = True
|
||||||
|
if author_type == 'MODERATOR':
|
||||||
|
isChatModerator = True
|
||||||
|
if badge["liveChatAuthorBadgeRenderer"].get("customThumbnail"):
|
||||||
isChatSponsor = True
|
isChatSponsor = True
|
||||||
|
self.author.type = 'MEMBER'
|
||||||
self.get_badgeurl(badge)
|
self.get_badgeurl(badge)
|
||||||
if author_type == 'モデレーター':
|
|
||||||
isChatModerator = True
|
|
||||||
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
||||||
|
|
||||||
|
|
||||||
def get_badgeurl(self,badge):
|
def get_badgeurl(self, badge):
|
||||||
self.author.badgeUrl = badge["liveChatAuthorBadgeRenderer"]["customThumbnail"]["thumbnails"][0]["url"]
|
self.author.badgeUrl = badge["liveChatAuthorBadgeRenderer"]["customThumbnail"]["thumbnails"][0]["url"]
|
||||||
|
|
||||||
|
def get_datetime(self, timestamp):
|
||||||
|
dt = datetime.fromtimestamp(timestamp / 1000000)
|
||||||
def get_datetime(self,timestamp):
|
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
dt = datetime.fromtimestamp(timestamp/1000000)
|
|
||||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ symbols = {
|
|||||||
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
||||||
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
||||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"}
|
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||||
}
|
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "newSponsor")
|
super().__init__(item, "newSponsor")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_authordetails(self):
|
def get_authordetails(self):
|
||||||
super().get_authordetails()
|
super().get_authordetails()
|
||||||
self.author.isChatSponsor = True
|
self.author.isChatSponsor = True
|
||||||
|
|
||||||
|
def get_message(self, renderer):
|
||||||
def get_message(self,renderer):
|
|
||||||
message = (renderer["eventText"]["runs"][0]["text"]
|
message = (renderer["eventText"]["runs"][0]["text"]
|
||||||
)+' / '+(renderer["detailText"]["simpleText"])
|
) + ' / ' + (renderer["detailText"]["simpleText"])
|
||||||
return message
|
return message, [message]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
pytchat/processors/default/renderer/membership.py
Normal file
15
pytchat/processors/default/renderer/membership.py
Normal 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]
|
||||||
@@ -3,34 +3,44 @@ from . import currency
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LiveChatPaidMessageRenderer(BaseRenderer):
|
class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "superChat")
|
super().__init__(item, "superChat")
|
||||||
|
|
||||||
|
|
||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
super().get_snippet()
|
super().get_snippet()
|
||||||
|
amountDisplayString, symbol, amount = (
|
||||||
self.author.name = self.renderer["authorName"]["simpleText"]
|
|
||||||
|
|
||||||
amountDisplayString, symbol, amount =(
|
|
||||||
self.get_amountdata(self.renderer)
|
self.get_amountdata(self.renderer)
|
||||||
)
|
)
|
||||||
self.message = self.get_message(self.renderer)
|
self.amountValue = amount
|
||||||
self.amountValue= amount
|
|
||||||
self.amountString = amountDisplayString
|
self.amountString = amountDisplayString
|
||||||
self.currency= currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol
|
self.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(
|
||||||
self.bgColor= self.renderer.get("bodyBackgroundColor", 0)
|
symbol) else symbol
|
||||||
|
self.bgColor = self.renderer.get("bodyBackgroundColor", 0)
|
||||||
|
self.colors = self.get_colors()
|
||||||
|
|
||||||
|
def get_amountdata(self, renderer):
|
||||||
def get_amountdata(self,renderer):
|
|
||||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||||
m = superchat_regex.search(amountDisplayString)
|
m = superchat_regex.search(amountDisplayString)
|
||||||
if m:
|
if m:
|
||||||
symbol = m.group(1)
|
symbol = m.group(1)
|
||||||
amount = float(m.group(2).replace(',',''))
|
amount = float(m.group(2).replace(',', ''))
|
||||||
else:
|
else:
|
||||||
symbol = ""
|
symbol = ""
|
||||||
amount = 0.0
|
amount = 0.0
|
||||||
return amountDisplayString, symbol, amount
|
return amountDisplayString, symbol, amount
|
||||||
|
|
||||||
|
def get_colors(self):
|
||||||
|
colors = Colors()
|
||||||
|
colors.headerBackgroundColor = self.renderer.get("headerBackgroundColor", 0)
|
||||||
|
colors.headerTextColor = self.renderer.get("headerTextColor", 0)
|
||||||
|
colors.bodyBackgroundColor = self.renderer.get("bodyBackgroundColor", 0)
|
||||||
|
colors.bodyTextColor = self.renderer.get("bodyTextColor", 0)
|
||||||
|
colors.timestampColor = self.renderer.get("timestampColor", 0)
|
||||||
|
colors.authorNameTextColor = self.renderer.get("authorNameTextColor", 0)
|
||||||
|
return colors
|
||||||
|
|||||||
@@ -3,41 +3,44 @@ from . import currency
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LiveChatPaidStickerRenderer(BaseRenderer):
|
class LiveChatPaidStickerRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "superSticker")
|
super().__init__(item, "superSticker")
|
||||||
|
|
||||||
|
|
||||||
def get_snippet(self):
|
def get_snippet(self):
|
||||||
super().get_snippet()
|
super().get_snippet()
|
||||||
|
amountDisplayString, symbol, amount = (
|
||||||
self.author.name = self.renderer["authorName"]["simpleText"]
|
|
||||||
|
|
||||||
amountDisplayString, symbol, amount =(
|
|
||||||
self.get_amountdata(self.renderer)
|
self.get_amountdata(self.renderer)
|
||||||
)
|
)
|
||||||
self.message = ""
|
|
||||||
self.amountValue = amount
|
self.amountValue = amount
|
||||||
self.amountString = amountDisplayString
|
self.amountString = amountDisplayString
|
||||||
self.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol
|
self.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(
|
||||||
|
symbol) else symbol
|
||||||
self.bgColor = self.renderer.get("moneyChipBackgroundColor", 0)
|
self.bgColor = self.renderer.get("moneyChipBackgroundColor", 0)
|
||||||
self.sticker = "https:"+self.renderer["sticker"]["thumbnails"][0]["url"]
|
self.sticker = "".join(("https:",
|
||||||
|
self.renderer["sticker"]["thumbnails"][0]["url"]))
|
||||||
|
self.colors = self.get_colors()
|
||||||
|
|
||||||
|
def get_amountdata(self, renderer):
|
||||||
def get_amountdata(self,renderer):
|
|
||||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||||
m = superchat_regex.search(amountDisplayString)
|
m = superchat_regex.search(amountDisplayString)
|
||||||
if m:
|
if m:
|
||||||
symbol = m.group(1)
|
symbol = m.group(1)
|
||||||
amount = float(m.group(2).replace(',',''))
|
amount = float(m.group(2).replace(',', ''))
|
||||||
else:
|
else:
|
||||||
symbol = ""
|
symbol = ""
|
||||||
amount = 0.0
|
amount = 0.0
|
||||||
return amountDisplayString, symbol, amount
|
return amountDisplayString, symbol, amount
|
||||||
|
|
||||||
|
def get_colors(self):
|
||||||
|
colors = Colors()
|
||||||
|
colors.moneyChipBackgroundColor = self.renderer.get("moneyChipBackgroundColor", 0)
|
||||||
|
colors.moneyChipTextColor = self.renderer.get("moneyChipTextColor", 0)
|
||||||
|
colors.backgroundColor = self.renderer.get("backgroundColor", 0)
|
||||||
|
colors.authorNameTextColor = self.renderer.get("authorNameTextColor", 0)
|
||||||
|
return colors
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||||
def __init__(self, item):
|
def __init__(self, item):
|
||||||
super().__init__(item, "textMessage")
|
super().__init__(item, "textMessage")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from .chat_processor import ChatProcessor
|
from .chat_processor import ChatProcessor
|
||||||
|
|
||||||
|
|
||||||
class DummyProcessor(ChatProcessor):
|
class DummyProcessor(ChatProcessor):
|
||||||
'''
|
'''
|
||||||
Dummy processor just returns received chat_components directly.
|
Dummy processor just returns received chat_components directly.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def process(self, chat_components: list):
|
def process(self, chat_components: list):
|
||||||
return chat_components
|
return chat_components
|
||||||
|
|||||||
137
pytchat/processors/html_archiver.py
Normal file
137
pytchat/processors/html_archiver.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from base64 import standard_b64encode
|
||||||
|
from .chat_processor import ChatProcessor
|
||||||
|
from .default.processor import DefaultProcessor
|
||||||
|
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||||
|
|
||||||
|
fmt_headers = ['datetime', 'elapsed', 'authorName',
|
||||||
|
'message', 'superchat', 'type', 'authorChannel']
|
||||||
|
|
||||||
|
HEADER_HTML = '''
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
|
||||||
|
'''
|
||||||
|
|
||||||
|
TABLE_CSS = '''
|
||||||
|
table.css {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.css thead{
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #000
|
||||||
|
}
|
||||||
|
|
||||||
|
table.css tr td{
|
||||||
|
padding: 0.3em;
|
||||||
|
border: 1px solid #000
|
||||||
|
}
|
||||||
|
|
||||||
|
table.css th{
|
||||||
|
padding: 0.3em;
|
||||||
|
border: 1px solid #000
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLArchiver(ChatProcessor):
|
||||||
|
'''
|
||||||
|
HTMLArchiver saves chat data as HTML table format.
|
||||||
|
'''
|
||||||
|
def __init__(self, save_path):
|
||||||
|
super().__init__()
|
||||||
|
self.save_path = self._checkpath(save_path)
|
||||||
|
self.processor = DefaultProcessor()
|
||||||
|
self.emoji_table = {} # tuble for custom emojis. key: emoji_id, value: base64 encoded image binary.
|
||||||
|
self.header = [HEADER_HTML]
|
||||||
|
self.body = ['<body>\n', '<table class="css">\n', self._parse_table_header(fmt_headers)]
|
||||||
|
|
||||||
|
def _checkpath(self, filepath):
|
||||||
|
splitter = os.path.splitext(os.path.basename(filepath))
|
||||||
|
body = splitter[0]
|
||||||
|
extention = splitter[1]
|
||||||
|
newpath = filepath
|
||||||
|
counter = 0
|
||||||
|
while os.path.exists(newpath):
|
||||||
|
match = re.search(PATTERN, body)
|
||||||
|
if match:
|
||||||
|
counter = int(match[2]) + 1
|
||||||
|
num_with_bracket = f'({str(counter)})'
|
||||||
|
body = f'{match[1]}{num_with_bracket}'
|
||||||
|
else:
|
||||||
|
body = f'{body}({str(counter)})'
|
||||||
|
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||||
|
return newpath
|
||||||
|
|
||||||
|
def process(self, chat_components: list):
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
----------
|
||||||
|
dict :
|
||||||
|
save_path : str :
|
||||||
|
Actual save path of file.
|
||||||
|
total_lines : int :
|
||||||
|
count of total lines written to the file.
|
||||||
|
"""
|
||||||
|
if chat_components is None or len(chat_components) == 0:
|
||||||
|
return
|
||||||
|
self.body.extend(
|
||||||
|
(self._parse_html_line((
|
||||||
|
c.datetime,
|
||||||
|
c.elapsedTime,
|
||||||
|
c.author.name,
|
||||||
|
self._parse_message(c.messageEx),
|
||||||
|
c.amountString,
|
||||||
|
c.author.type,
|
||||||
|
c.author.channelId)
|
||||||
|
) for c in self.processor.process(chat_components).items)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_html_line(self, raw_line):
|
||||||
|
return ''.join(('<tr>',
|
||||||
|
''.join(''.join(('<td>', cell, '</td>')) for cell in raw_line),
|
||||||
|
'</tr>\n'))
|
||||||
|
|
||||||
|
def _parse_table_header(self, raw_line):
|
||||||
|
return ''.join(('<thead><tr>',
|
||||||
|
''.join(''.join(('<th>', cell, '</th>')) for cell in raw_line),
|
||||||
|
'</tr></thead>\n'))
|
||||||
|
|
||||||
|
def _parse_message(self, message_items: list) -> str:
|
||||||
|
return ''.join(''.join(('<span class="', self._set_emoji_table(item), '" title="', item['txt'], '"></span>'))
|
||||||
|
if type(item) is dict else item
|
||||||
|
for item in message_items)
|
||||||
|
|
||||||
|
def _encode_img(self, url):
|
||||||
|
resp = requests.get(url)
|
||||||
|
return standard_b64encode(resp.content).decode()
|
||||||
|
|
||||||
|
def _set_emoji_table(self, item: dict):
|
||||||
|
emoji_id = item['id']
|
||||||
|
if emoji_id not in self.emoji_table:
|
||||||
|
self.emoji_table.setdefault(emoji_id, self._encode_img(item['url']))
|
||||||
|
return emoji_id
|
||||||
|
|
||||||
|
def _stylecode(self, name, code, width, height):
|
||||||
|
return ''.join((".", name, " { display: inline-block; background-image: url(data:image/png;base64,",
|
||||||
|
code, "); background-repeat: no-repeat; width: ",
|
||||||
|
str(width), "; height: ", str(height), ";}"))
|
||||||
|
|
||||||
|
def _create_styles(self):
|
||||||
|
return '\n'.join(('<style type="text/css">',
|
||||||
|
TABLE_CSS,
|
||||||
|
'\n'.join(self._stylecode(key, self.emoji_table[key], 24, 24)
|
||||||
|
for key in self.emoji_table.keys()),
|
||||||
|
'</style>\n'))
|
||||||
|
|
||||||
|
def finalize(self):
|
||||||
|
self.header.extend([self._create_styles(), '</head>\n'])
|
||||||
|
self.body.extend(['</table>\n</body>'])
|
||||||
|
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||||
|
f.writelines(self.header)
|
||||||
|
f.writelines(self.body)
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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])
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
69
pytchat/processors/jsonfile_archiver.py
Normal file
69
pytchat/processors/jsonfile_archiver.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from .chat_processor import ChatProcessor
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||||
|
|
||||||
|
|
||||||
|
class JsonfileArchiver(ChatProcessor):
|
||||||
|
"""
|
||||||
|
JsonfileArchiver saves chat data as text of JSON lines.
|
||||||
|
|
||||||
|
Parameter:
|
||||||
|
----------
|
||||||
|
save_path : str :
|
||||||
|
save path of file.If a file with the same name exists,
|
||||||
|
it is automatically saved under a different name
|
||||||
|
with suffix '(number)'
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, save_path):
|
||||||
|
super().__init__()
|
||||||
|
self.save_path = self._checkpath(save_path)
|
||||||
|
self.line_counter = 0
|
||||||
|
|
||||||
|
def process(self, chat_components: list):
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
----------
|
||||||
|
dict :
|
||||||
|
save_path : str :
|
||||||
|
Actual save path of file.
|
||||||
|
total_lines : int :
|
||||||
|
count of total lines written to the file.
|
||||||
|
"""
|
||||||
|
if chat_components is None:
|
||||||
|
return
|
||||||
|
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||||
|
for component in chat_components:
|
||||||
|
if component is None:
|
||||||
|
continue
|
||||||
|
chatdata = component.get('chatdata')
|
||||||
|
if chatdata is None:
|
||||||
|
continue
|
||||||
|
for action in chatdata:
|
||||||
|
if action is None:
|
||||||
|
continue
|
||||||
|
json_line = json.dumps(action, ensure_ascii=False)
|
||||||
|
f.writelines(json_line + '\n')
|
||||||
|
self.line_counter += 1
|
||||||
|
return {"save_path": self.save_path,
|
||||||
|
"total_lines": self.line_counter}
|
||||||
|
|
||||||
|
def _checkpath(self, filepath):
|
||||||
|
splitter = os.path.splitext(os.path.basename(filepath))
|
||||||
|
body = splitter[0]
|
||||||
|
extention = splitter[1]
|
||||||
|
newpath = filepath
|
||||||
|
counter = 0
|
||||||
|
while os.path.exists(newpath):
|
||||||
|
match = re.search(PATTERN, body)
|
||||||
|
if match:
|
||||||
|
counter = int(match[2]) + 1
|
||||||
|
num_with_bracket = f'({str(counter)})'
|
||||||
|
body = f'{match[1]}{num_with_bracket}'
|
||||||
|
else:
|
||||||
|
body = f'{body}({str(counter)})'
|
||||||
|
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||||
|
return newpath
|
||||||
@@ -1,47 +1,49 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import traceback
|
|
||||||
import datetime
|
|
||||||
import time
|
|
||||||
from .chat_processor import ChatProcessor
|
from .chat_processor import ChatProcessor
|
||||||
##version 2
|
|
||||||
|
|
||||||
class SimpleDisplayProcessor(ChatProcessor):
|
class SimpleDisplayProcessor(ChatProcessor):
|
||||||
|
|
||||||
def process(self, chat_components: list):
|
def process(self, chat_components: list):
|
||||||
chatlist = []
|
chatlist = []
|
||||||
timeout = 0
|
timeout = 0
|
||||||
|
|
||||||
if chat_components is None:
|
if chat_components is None:
|
||||||
return {"timeout":timeout, "chatlist":chatlist}
|
return {"timeout": timeout, "chatlist": chatlist}
|
||||||
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: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 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:
|
if root:
|
||||||
author_name = root['authorName']['simpleText']
|
author_name = root['authorName']['simpleText']
|
||||||
message = self._parse_message(root.get('message'))
|
message = self._parse_message(root.get('message'))
|
||||||
purchase_amount_text = ''
|
purchase_amount_text = ''
|
||||||
else:
|
else:
|
||||||
root = ( action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer') or
|
root = (action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer')
|
||||||
action['addChatItemAction']['item'].get('liveChatPaidStickerRenderer') )
|
or action['addChatItemAction']['item'].get('liveChatPaidStickerRenderer'))
|
||||||
if root:
|
if root:
|
||||||
author_name = root['authorName']['simpleText']
|
author_name = root['authorName']['simpleText']
|
||||||
message = self._parse_message(root.get('message'))
|
message = self._parse_message(root.get('message'))
|
||||||
purchase_amount_text = root['purchaseAmountText']['simpleText']
|
purchase_amount_text = root['purchaseAmountText']['simpleText']
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
chatlist.append(f'[{author_name}]: {message} {purchase_amount_text}')
|
chatlist.append(
|
||||||
return {"timeout":timeout, "chatlist":chatlist}
|
f'[{author_name}]: {message} {purchase_amount_text}')
|
||||||
|
return {"timeout": timeout, "chatlist": chatlist}
|
||||||
def _parse_message(self,message):
|
|
||||||
|
def _parse_message(self, message):
|
||||||
if message is None:
|
if message is None:
|
||||||
return ''
|
return ''
|
||||||
if message.get('simpleText'):
|
if message.get('simpleText'):
|
||||||
@@ -51,11 +53,9 @@ class SimpleDisplayProcessor(ChatProcessor):
|
|||||||
tmp = ''
|
tmp = ''
|
||||||
for run in runs:
|
for run in runs:
|
||||||
if run.get('emoji'):
|
if run.get('emoji'):
|
||||||
tmp+=(run['emoji']['shortcuts'][0])
|
tmp += (run['emoji']['shortcuts'][0])
|
||||||
elif run.get('text'):
|
elif run.get('text'):
|
||||||
tmp+=(run['text'])
|
tmp += (run['text'])
|
||||||
return tmp
|
return tmp
|
||||||
else:
|
else:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
pytchat/processors/speed/__init__.py
Normal file
0
pytchat/processors/speed/__init__.py
Normal file
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
speedmeter.py
|
speed_calculator.py
|
||||||
チャットの勢いを算出するChatProcessor
|
チャットの勢いを算出するChatProcessor
|
||||||
Calculate speed of chat.
|
Calculate speed of chat.
|
||||||
"""
|
"""
|
||||||
import calendar, datetime, pytz
|
import time
|
||||||
from .chat_processor import ChatProcessor
|
from .. chat_processor import ChatProcessor
|
||||||
|
|
||||||
|
|
||||||
class RingQueue:
|
class RingQueue:
|
||||||
"""
|
"""
|
||||||
リング型キュー
|
リング型キュー
|
||||||
|
|
||||||
Attributes
|
Attributes
|
||||||
----------
|
----------
|
||||||
items : list
|
items : list
|
||||||
@@ -21,10 +23,10 @@ class RingQueue:
|
|||||||
キュー内に余裕があるか。キュー内のアイテム個数が、キューの最大個数未満であればTrue。
|
キュー内に余裕があるか。キュー内のアイテム個数が、キューの最大個数未満であればTrue。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, capacity):
|
def __init__(self, capacity):
|
||||||
"""
|
"""
|
||||||
コンストラクタ
|
コンストラクタ
|
||||||
|
|
||||||
Parameter
|
Parameter
|
||||||
----------
|
----------
|
||||||
capacity:このキューに格納するアイテムの最大個数。
|
capacity:このキューに格納するアイテムの最大個数。
|
||||||
@@ -50,17 +52,17 @@ class RingQueue:
|
|||||||
"""
|
"""
|
||||||
if self.mergin:
|
if self.mergin:
|
||||||
self.items.append(item)
|
self.items.append(item)
|
||||||
self.last_pos = len(self.items)-1
|
self.last_pos = len(self.items) - 1
|
||||||
if self.last_pos == self.capacity-1:
|
if self.last_pos == self.capacity - 1:
|
||||||
self.mergin = False
|
self.mergin = False
|
||||||
return
|
return
|
||||||
self.last_pos += 1
|
self.last_pos += 1
|
||||||
if self.last_pos > self.capacity-1:
|
if self.last_pos > self.capacity - 1:
|
||||||
self.last_pos = 0
|
self.last_pos = 0
|
||||||
self.items[self.last_pos] = item
|
self.items[self.last_pos] = item
|
||||||
|
|
||||||
self.first_pos += 1
|
self.first_pos += 1
|
||||||
if self.first_pos > self.capacity-1:
|
if self.first_pos > self.capacity - 1:
|
||||||
self.first_pos = 0
|
self.first_pos = 0
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -76,11 +78,12 @@ class RingQueue:
|
|||||||
|
|
||||||
def item_count(self):
|
def item_count(self):
|
||||||
return len(self.items)
|
return len(self.items)
|
||||||
|
|
||||||
|
|
||||||
class SpeedCalculator(ChatProcessor, RingQueue):
|
class SpeedCalculator(ChatProcessor, RingQueue):
|
||||||
"""
|
"""
|
||||||
チャットの勢いを計算する。
|
チャットの勢いを計算する。
|
||||||
|
|
||||||
一定期間のチャットデータのうち、最初のチャットの投稿時刻と
|
一定期間のチャットデータのうち、最初のチャットの投稿時刻と
|
||||||
最後のチャットの投稿時刻の差を、チャット数で割り返し
|
最後のチャットの投稿時刻の差を、チャット数で割り返し
|
||||||
1分あたりの速度に換算する。
|
1分あたりの速度に換算する。
|
||||||
@@ -91,7 +94,7 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
|||||||
RingQueueに格納するチャット勢い算出用データの最大数
|
RingQueueに格納するチャット勢い算出用データの最大数
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, capacity = 10):
|
def __init__(self, capacity=10):
|
||||||
super().__init__(capacity)
|
super().__init__(capacity)
|
||||||
self.speed = 0
|
self.speed = 0
|
||||||
|
|
||||||
@@ -105,7 +108,6 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
|||||||
self._put_chatdata(chatdata)
|
self._put_chatdata(chatdata)
|
||||||
self.speed = self._calc_speed()
|
self.speed = self._calc_speed()
|
||||||
return self.speed
|
return self.speed
|
||||||
|
|
||||||
|
|
||||||
def _calc_speed(self):
|
def _calc_speed(self):
|
||||||
"""
|
"""
|
||||||
@@ -116,14 +118,13 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
|||||||
---------------------------
|
---------------------------
|
||||||
チャット速度(1分間で換算したチャット数)
|
チャット速度(1分間で換算したチャット数)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
#キュー内の総チャット数
|
# キュー内の総チャット数
|
||||||
total = sum(item['chat_count'] for item in self.items)
|
total = sum(item['chat_count'] for item in self.items)
|
||||||
#キュー内の最初と最後のチャットの時間差
|
# キュー内の最初と最後のチャットの時間差
|
||||||
duration = (self.items[self.last_pos]['endtime']
|
duration = (self.items[self.last_pos]['endtime'] - self.items[self.first_pos]['starttime'])
|
||||||
- self.items[self.first_pos]['starttime'])
|
|
||||||
if duration != 0:
|
if duration != 0:
|
||||||
return int(total*60/duration)
|
return int(total * 60 / duration)
|
||||||
return 0
|
return 0
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return 0
|
return 0
|
||||||
@@ -143,62 +144,60 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
|||||||
'''
|
'''
|
||||||
チャットデータがない場合に空のデータをキューに投入する。
|
チャットデータがない場合に空のデータをキューに投入する。
|
||||||
'''
|
'''
|
||||||
timestamp_now = calendar.timegm(datetime.datetime.
|
timestamp_now = int(time.time())
|
||||||
now(pytz.utc).utctimetuple())
|
|
||||||
self.put({
|
self.put({
|
||||||
'chat_count':0,
|
'chat_count': 0,
|
||||||
'starttime':int(timestamp_now),
|
'starttime': int(timestamp_now),
|
||||||
'endtime':int(timestamp_now)
|
'endtime': int(timestamp_now)
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_timestamp(action :dict):
|
def _get_timestamp(action: dict):
|
||||||
"""
|
"""
|
||||||
チャットデータから時刻データを取り出す。
|
チャットデータから時刻データを取り出す。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
item = action['addChatItemAction']['item']
|
item = action['addChatItemAction']['item']
|
||||||
timestamp = int(item[list(item.keys())[0]]['timestampUsec'])
|
timestamp = int(item[list(item.keys())[0]]['timestampUsec'])
|
||||||
except (KeyError,TypeError):
|
except (KeyError, TypeError):
|
||||||
return None
|
return None
|
||||||
return timestamp
|
return timestamp
|
||||||
|
|
||||||
if actions is None or len(actions)==0:
|
if actions is None or len(actions) == 0:
|
||||||
_put_emptydata()
|
_put_emptydata()
|
||||||
return
|
return
|
||||||
|
|
||||||
#actions内の時刻データを持つチャットデータの数
|
# actions内の時刻データを持つチャットデータの数
|
||||||
counter=0
|
counter = 0
|
||||||
#actions内の最初のチャットデータの時刻
|
# actions内の最初のチャットデータの時刻
|
||||||
starttime= None
|
starttime = None
|
||||||
#actions内の最後のチャットデータの時刻
|
# actions内の最後のチャットデータの時刻
|
||||||
endtime=None
|
endtime = None
|
||||||
|
|
||||||
for action in actions:
|
for action in actions:
|
||||||
#チャットデータからtimestampUsecを読み取る
|
# チャットデータからtimestampUsecを読み取る
|
||||||
gettime = _get_timestamp(action)
|
gettime = _get_timestamp(action)
|
||||||
|
|
||||||
#時刻のないデータだった場合は次の行のデータで読み取り試行
|
# 時刻のないデータだった場合は次の行のデータで読み取り試行
|
||||||
if gettime is None:
|
if gettime is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#最初に有効な時刻を持つデータのtimestampをstarttimeに設定
|
# 最初に有効な時刻を持つデータのtimestampをstarttimeに設定
|
||||||
if starttime is None:
|
if starttime is None:
|
||||||
starttime = gettime
|
starttime = gettime
|
||||||
|
|
||||||
#最後のtimestampを設定(途中で時刻のないデータの場合もあるので上書きしていく)
|
# 最後のtimestampを設定(途中で時刻のないデータの場合もあるので上書きしていく)
|
||||||
endtime = gettime
|
endtime = gettime
|
||||||
|
|
||||||
#チャットの数をインクリメント
|
# チャットの数をインクリメント
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
#チャット速度用のデータをRingQueueに送る
|
# チャット速度用のデータをRingQueueに送る
|
||||||
if starttime is None or endtime is None:
|
if starttime is None or endtime is None:
|
||||||
_put_emptydata()
|
_put_emptydata()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.put({
|
|
||||||
'chat_count':counter,
|
|
||||||
'starttime':int(starttime/1000000),
|
|
||||||
'endtime':int(endtime/1000000)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
self.put({
|
||||||
|
'chat_count': counter,
|
||||||
|
'starttime': int(starttime / 1000000),
|
||||||
|
'endtime': int(endtime / 1000000)
|
||||||
|
})
|
||||||
0
pytchat/processors/superchat/__init__.py
Normal file
0
pytchat/processors/superchat/__init__.py
Normal file
75
pytchat/processors/superchat/calculator.py
Normal file
75
pytchat/processors/superchat/calculator.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import re
|
||||||
|
from pytchat.processors.chat_processor import ChatProcessor
|
||||||
|
|
||||||
|
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||||
|
|
||||||
|
items_paid = [
|
||||||
|
'addChatItemAction',
|
||||||
|
'item',
|
||||||
|
'liveChatPaidMessageRenderer'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_sticker = [
|
||||||
|
'addChatItemAction',
|
||||||
|
'item',
|
||||||
|
'liveChatPaidStickerRenderer'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SuperchatCalculator(ChatProcessor):
|
||||||
|
"""
|
||||||
|
Calculate the amount of SuperChat by currency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
def process(self, chat_components: list):
|
||||||
|
"""
|
||||||
|
Return
|
||||||
|
------------
|
||||||
|
results : dict :
|
||||||
|
List of amount by currency.
|
||||||
|
key: currency symbol, value: total amount.
|
||||||
|
"""
|
||||||
|
if chat_components is None:
|
||||||
|
return self.results
|
||||||
|
for component in chat_components:
|
||||||
|
chatdata = component.get('chatdata')
|
||||||
|
if chatdata is None:
|
||||||
|
continue
|
||||||
|
for action in chatdata:
|
||||||
|
renderer = self._get_item(action, items_paid) or \
|
||||||
|
self._get_item(action, items_sticker)
|
||||||
|
if renderer is None:
|
||||||
|
continue
|
||||||
|
symbol, amount = self._parse(renderer)
|
||||||
|
self.results.setdefault(symbol, 0)
|
||||||
|
self.results[symbol] += amount
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
def _parse(self, renderer):
|
||||||
|
purchase_amount_text = renderer["purchaseAmountText"]["simpleText"]
|
||||||
|
m = superchat_regex.search(purchase_amount_text)
|
||||||
|
if m:
|
||||||
|
symbol = m.group(1)
|
||||||
|
amount = float(m.group(2).replace(',', ''))
|
||||||
|
else:
|
||||||
|
symbol = ""
|
||||||
|
amount = 0.0
|
||||||
|
return symbol, amount
|
||||||
|
|
||||||
|
def _get_item(self, dict_body, items: list):
|
||||||
|
for item in items:
|
||||||
|
if dict_body is None:
|
||||||
|
break
|
||||||
|
if isinstance(dict_body, dict):
|
||||||
|
dict_body = dict_body.get(item)
|
||||||
|
continue
|
||||||
|
if isinstance(item, int) and \
|
||||||
|
isinstance(dict_body, list) and \
|
||||||
|
len(dict_body) > item:
|
||||||
|
dict_body = dict_body[item]
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return dict_body
|
||||||
67
pytchat/processors/tsv_archiver.py
Normal file
67
pytchat/processors/tsv_archiver.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from .chat_processor import ChatProcessor
|
||||||
|
from .default.processor import DefaultProcessor
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||||
|
fmt_headers = ['datetime', 'elapsed', 'authorName', 'message',
|
||||||
|
'superchatAmount', 'authorType', 'authorChannel']
|
||||||
|
|
||||||
|
|
||||||
|
class TSVArchiver(ChatProcessor):
|
||||||
|
'''
|
||||||
|
TsvArchiver saves chat data as Tab Separated Values format text.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self, save_path):
|
||||||
|
super().__init__()
|
||||||
|
self.save_path = self._checkpath(save_path)
|
||||||
|
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||||
|
writer = csv.writer(f, delimiter='\t')
|
||||||
|
writer.writerow(fmt_headers)
|
||||||
|
self.processor = DefaultProcessor()
|
||||||
|
|
||||||
|
def _checkpath(self, filepath):
|
||||||
|
splitter = os.path.splitext(os.path.basename(filepath))
|
||||||
|
body = splitter[0]
|
||||||
|
extention = splitter[1]
|
||||||
|
newpath = filepath
|
||||||
|
counter = 0
|
||||||
|
while os.path.exists(newpath):
|
||||||
|
match = re.search(PATTERN, body)
|
||||||
|
if match:
|
||||||
|
counter = int(match[2]) + 1
|
||||||
|
num_with_bracket = f'({str(counter)})'
|
||||||
|
body = f'{match[1]}{num_with_bracket}'
|
||||||
|
else:
|
||||||
|
body = f'{body}({str(counter)})'
|
||||||
|
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||||
|
return newpath
|
||||||
|
|
||||||
|
def process(self, chat_components: list):
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
----------
|
||||||
|
dict :
|
||||||
|
save_path : str :
|
||||||
|
Actual save path of file.
|
||||||
|
total_lines : int :
|
||||||
|
count of total lines written to the file.
|
||||||
|
"""
|
||||||
|
if chat_components is None or len(chat_components) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||||
|
writer = csv.writer(f, delimiter='\t')
|
||||||
|
chats = self.processor.process(chat_components).items
|
||||||
|
for c in chats:
|
||||||
|
writer.writerow([
|
||||||
|
c.datetime,
|
||||||
|
c.elapsedTime,
|
||||||
|
c.author.name,
|
||||||
|
c.message,
|
||||||
|
c.amountString,
|
||||||
|
c.author.type,
|
||||||
|
c.author.channelId
|
||||||
|
])
|
||||||
0
pytchat/tool/__init__.py
Normal file
0
pytchat/tool/__init__.py
Normal file
0
pytchat/tool/extract/__init__.py
Normal file
0
pytchat/tool/extract/__init__.py
Normal file
159
pytchat/tool/extract/asyncdl.py
Normal file
159
pytchat/tool/extract/asyncdl.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from . import parser
|
||||||
|
from . block import Block
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Split section from `start` to `end` into `count` pieces,
|
||||||
|
and returns the beginning of each piece.
|
||||||
|
The `count` is adjusted so that the length of each piece
|
||||||
|
is no smaller than `min_interval`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
--------
|
||||||
|
List of the offset of each block's first chat data.
|
||||||
|
"""
|
||||||
|
if not (isinstance(start, int) or isinstance(start, float)) or \
|
||||||
|
not (isinstance(end, int) or isinstance(end, float)):
|
||||||
|
raise ValueError("start/end must be int or float")
|
||||||
|
if not isinstance(count, int):
|
||||||
|
raise ValueError("count must be int")
|
||||||
|
if start > end:
|
||||||
|
raise ValueError("end must be equal to or greater than start.")
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError("count must be equal to or greater than 1.")
|
||||||
|
if (end - start) / count < min_interval_sec:
|
||||||
|
count = int((end - start) / min_interval_sec)
|
||||||
|
if count == 0:
|
||||||
|
count = 1
|
||||||
|
interval = (end - start) / count
|
||||||
|
|
||||||
|
if count == 1:
|
||||||
|
return [start]
|
||||||
|
return sorted(list(set([int(start + interval * j)
|
||||||
|
for j in range(count)])))
|
||||||
|
|
||||||
|
|
||||||
|
def ready_blocks(video_id, duration, div, callback):
|
||||||
|
if div <= 0:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
async def _get_blocks(video_id, duration, div, callback):
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [_create_block(session, video_id, seektime, callback)
|
||||||
|
for seektime in _split(-1, duration, div)]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
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])
|
||||||
|
if callback:
|
||||||
|
callback(actions, last - first)
|
||||||
|
return Block(
|
||||||
|
continuation=next_continuation,
|
||||||
|
chat_data=actions,
|
||||||
|
first=first,
|
||||||
|
last=last
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
fetch initial blocks.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
blocks = loop.run_until_complete(
|
||||||
|
_get_blocks(video_id, duration, div, callback))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_patch(callback, blocks, video_id):
|
||||||
|
|
||||||
|
async def _allocate_workers():
|
||||||
|
workers = [
|
||||||
|
ExtractWorker(
|
||||||
|
fetch=_fetch, block=block,
|
||||||
|
blocks=blocks, video_id=video_id
|
||||||
|
)
|
||||||
|
for block in blocks
|
||||||
|
]
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [worker.run(session) for worker in workers]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
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])
|
||||||
|
if callback:
|
||||||
|
callback(actions, last - first)
|
||||||
|
return Patch(actions, continuation, first, last)
|
||||||
|
return Patch(continuation=continuation)
|
||||||
|
|
||||||
|
"""
|
||||||
|
allocate workers and assign blocks.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(_allocate_workers())
|
||||||
|
except CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _shutdown():
|
||||||
|
print("\nshutdown...")
|
||||||
|
tasks = [t for t in asyncio.all_tasks()
|
||||||
|
if t is not asyncio.current_task()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cancel():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(_shutdown())
|
||||||
56
pytchat/tool/extract/block.py
Normal file
56
pytchat/tool/extract/block.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
class Block:
|
||||||
|
"""Block object represents something like a box
|
||||||
|
to join chunk of chatdata.
|
||||||
|
|
||||||
|
Parameter:
|
||||||
|
---------
|
||||||
|
first : int :
|
||||||
|
videoOffsetTimeMs of the first chat_data
|
||||||
|
(chat_data[0])
|
||||||
|
|
||||||
|
last : int :
|
||||||
|
videoOffsetTimeMs of the last chat_data.
|
||||||
|
(chat_data[-1])
|
||||||
|
|
||||||
|
this value increases as fetching chatdata progresses.
|
||||||
|
|
||||||
|
end : int :
|
||||||
|
target videoOffsetTimeMs of last chat data for extract,
|
||||||
|
equals to first videoOffsetTimeMs of next block.
|
||||||
|
when extract worker reaches this offset, stop fetching.
|
||||||
|
|
||||||
|
continuation : str :
|
||||||
|
continuation param of last chat data.
|
||||||
|
|
||||||
|
chat_data : list
|
||||||
|
|
||||||
|
done : bool :
|
||||||
|
whether this block has been fetched.
|
||||||
|
|
||||||
|
remaining : int :
|
||||||
|
remaining data to extract.
|
||||||
|
equals end - last.
|
||||||
|
|
||||||
|
is_last : bool :
|
||||||
|
whether this block is the last one in blocklist.
|
||||||
|
|
||||||
|
during_split : bool :
|
||||||
|
whether this block is in the process of during_split.
|
||||||
|
while True, this block is excluded from duplicate split procedure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ['first', 'last', 'end', 'continuation', 'chat_data', 'remaining',
|
||||||
|
'done', 'is_last', 'during_split']
|
||||||
|
|
||||||
|
def __init__(self, first=0, last=0, end=0,
|
||||||
|
continuation='', chat_data=[], is_last=False,
|
||||||
|
during_split=False):
|
||||||
|
self.first = first
|
||||||
|
self.last = last
|
||||||
|
self.end = end
|
||||||
|
self.continuation = continuation
|
||||||
|
self.chat_data = chat_data
|
||||||
|
self.done = False
|
||||||
|
self.remaining = self.end - self.last
|
||||||
|
self.is_last = is_last
|
||||||
|
self.during_split = during_split
|
||||||
153
pytchat/tool/extract/duplcheck.py
Normal file
153
pytchat/tool/extract/duplcheck.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
from . import parser
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate(chatdata):
|
||||||
|
max_range = len(chatdata) - 1
|
||||||
|
tbl_offset = [None] * max_range
|
||||||
|
tbl_id = [None] * max_range
|
||||||
|
tbl_type = [None] * max_range
|
||||||
|
|
||||||
|
def create_table(chatdata, max_range):
|
||||||
|
for i in range(max_range):
|
||||||
|
tbl_offset[i] = parser.get_offset(chatdata[i])
|
||||||
|
tbl_id[i] = parser.get_id(chatdata[i])
|
||||||
|
tbl_type[i] = parser.get_type(chatdata[i])
|
||||||
|
|
||||||
|
def is_duplicate(i, j):
|
||||||
|
return (
|
||||||
|
tbl_offset[i] == tbl_offset[j]
|
||||||
|
and tbl_id[i] == tbl_id[j]
|
||||||
|
and tbl_type[i] == tbl_type[j]
|
||||||
|
)
|
||||||
|
print("creating table...")
|
||||||
|
create_table(chatdata, max_range)
|
||||||
|
print("searching duplicate data...")
|
||||||
|
return [{"i": {
|
||||||
|
"index": i, "id": parser.get_id(chatdata[i]),
|
||||||
|
"offsetTime": parser.get_offset(chatdata[i]),
|
||||||
|
"type": parser.get_type(chatdata[i])
|
||||||
|
},
|
||||||
|
"j":{
|
||||||
|
"index": j, "id": parser.get_id(chatdata[j]),
|
||||||
|
"offsetTime": parser.get_offset(chatdata[j]),
|
||||||
|
"type": parser.get_type(chatdata[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in range(max_range) for j in range(i + 1, max_range)
|
||||||
|
if is_duplicate(i, j)]
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate_offset(chatdata):
|
||||||
|
max_range = len(chatdata)
|
||||||
|
tbl_offset = [None] * max_range
|
||||||
|
tbl_id = [None] * max_range
|
||||||
|
tbl_type = [None] * max_range
|
||||||
|
|
||||||
|
def create_table(chatdata, max_range):
|
||||||
|
for i in range(max_range):
|
||||||
|
tbl_offset[i] = parser.get_offset(chatdata[i])
|
||||||
|
tbl_id[i] = parser.get_id(chatdata[i])
|
||||||
|
tbl_type[i] = parser.get_type(chatdata[i])
|
||||||
|
|
||||||
|
def is_duplicate(i, j):
|
||||||
|
return (
|
||||||
|
tbl_offset[i] == tbl_offset[j]
|
||||||
|
and tbl_id[i] == tbl_id[j]
|
||||||
|
)
|
||||||
|
|
||||||
|
print("creating table...")
|
||||||
|
create_table(chatdata, max_range)
|
||||||
|
print("searching duplicate data...")
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"index": i, "id": tbl_id[i],
|
||||||
|
"offsetTime": tbl_offset[i],
|
||||||
|
"type:": tbl_type[i]
|
||||||
|
}
|
||||||
|
for i in range(max_range - 1)
|
||||||
|
if is_duplicate(i, i + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def remove_duplicate_head(blocks):
|
||||||
|
if len(blocks) == 0 or len(blocks) == 1:
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def is_duplicate_head(index):
|
||||||
|
|
||||||
|
if len(blocks[index].chat_data) == 0:
|
||||||
|
return True
|
||||||
|
elif len(blocks[index + 1].chat_data) == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
id_0 = parser.get_id(blocks[index].chat_data[0])
|
||||||
|
id_1 = parser.get_id(blocks[index + 1].chat_data[0])
|
||||||
|
type_0 = parser.get_type(blocks[index].chat_data[0])
|
||||||
|
type_1 = parser.get_type(blocks[index + 1].chat_data[0])
|
||||||
|
return (
|
||||||
|
blocks[index].first == blocks[index + 1].first
|
||||||
|
and id_0 == id_1
|
||||||
|
and type_0 == type_1
|
||||||
|
)
|
||||||
|
ret = [blocks[i] for i in range(len(blocks) - 1)
|
||||||
|
if (len(blocks[i].chat_data) > 0
|
||||||
|
and not is_duplicate_head(i))]
|
||||||
|
ret.append(blocks[-1])
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def remove_duplicate_tail(blocks):
|
||||||
|
if len(blocks) == 0 or len(blocks) == 1:
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def is_duplicate_tail(index):
|
||||||
|
if len(blocks[index].chat_data) == 0:
|
||||||
|
return True
|
||||||
|
elif len(blocks[index - 1].chat_data) == 0:
|
||||||
|
return False
|
||||||
|
id_0 = parser.get_id(blocks[index - 1].chat_data[-1])
|
||||||
|
id_1 = parser.get_id(blocks[index].chat_data[-1])
|
||||||
|
type_0 = parser.get_type(blocks[index - 1].chat_data[-1])
|
||||||
|
type_1 = parser.get_type(blocks[index].chat_data[-1])
|
||||||
|
return (
|
||||||
|
blocks[index - 1].last == blocks[index].last
|
||||||
|
and id_0 == id_1
|
||||||
|
and type_0 == type_1
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = [blocks[i] for i in range(0, len(blocks))
|
||||||
|
if i == 0 or not is_duplicate_tail(i)]
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def remove_overlap(blocks):
|
||||||
|
"""
|
||||||
|
Fix overlapped blocks after ready_blocks().
|
||||||
|
Align the last offset of each block to the first offset
|
||||||
|
of next block (equals `end` offset of each block).
|
||||||
|
"""
|
||||||
|
if len(blocks) == 0 or len(blocks) == 1:
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
if block.is_last:
|
||||||
|
break
|
||||||
|
if len(block.chat_data) == 0:
|
||||||
|
continue
|
||||||
|
block_end = block.end
|
||||||
|
if block.last >= block_end:
|
||||||
|
for line in reversed(block.chat_data):
|
||||||
|
if parser.get_offset(line) < block_end:
|
||||||
|
break
|
||||||
|
block.chat_data.pop()
|
||||||
|
block.last = parser.get_offset(line)
|
||||||
|
block.remaining = 0
|
||||||
|
block.done = True
|
||||||
|
block.continuation = None
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(blocks):
|
||||||
|
print("---------- first last end---")
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
print(
|
||||||
|
f"block[{i:3}] {block.first:>10} {block.last:>10} {block.end:>10}")
|
||||||
96
pytchat/tool/extract/extractor.py
Normal file
96
pytchat/tool/extract/extractor.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from . import asyncdl
|
||||||
|
from . import duplcheck
|
||||||
|
from .. videoinfo import VideoInfo
|
||||||
|
from ... import config
|
||||||
|
from ... exceptions import InvalidVideoIdException
|
||||||
|
from ... util.extract_video_id import extract_video_id
|
||||||
|
|
||||||
|
logger = config.logger(__name__)
|
||||||
|
headers = config.headers
|
||||||
|
|
||||||
|
|
||||||
|
class Extractor:
|
||||||
|
def __init__(self, video_id, div=1, callback=None, processor=None):
|
||||||
|
if not isinstance(div, int) or div < 1:
|
||||||
|
raise ValueError('div must be positive integer.')
|
||||||
|
elif div > 10:
|
||||||
|
div = 10
|
||||||
|
self.video_id = extract_video_id(video_id)
|
||||||
|
self.div = div
|
||||||
|
self.callback = callback
|
||||||
|
self.processor = processor
|
||||||
|
self.duration = self._get_duration_of_video(video_id)
|
||||||
|
self.blocks = []
|
||||||
|
|
||||||
|
def _get_duration_of_video(self, video_id):
|
||||||
|
duration = 0
|
||||||
|
try:
|
||||||
|
duration = VideoInfo(video_id).get_duration()
|
||||||
|
except InvalidVideoIdException:
|
||||||
|
raise
|
||||||
|
return duration
|
||||||
|
|
||||||
|
def _ready_blocks(self):
|
||||||
|
blocks = asyncdl.ready_blocks(
|
||||||
|
self.video_id, self.duration, self.div, self.callback)
|
||||||
|
self.blocks = [block for block in blocks if block]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _remove_duplicate_head(self):
|
||||||
|
self.blocks = duplcheck.remove_duplicate_head(self.blocks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _set_block_end(self):
|
||||||
|
if len(self.blocks) > 0:
|
||||||
|
for i in range(len(self.blocks) - 1):
|
||||||
|
self.blocks[i].end = self.blocks[i + 1].first
|
||||||
|
self.blocks[-1].end = self.duration * 1000
|
||||||
|
self.blocks[-1].is_last = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _remove_overlap(self):
|
||||||
|
self.blocks = duplcheck.remove_overlap(self.blocks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _download_blocks(self):
|
||||||
|
asyncdl.fetch_patch(self.callback, self.blocks, self.video_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _remove_duplicate_tail(self):
|
||||||
|
self.blocks = duplcheck.remove_duplicate_tail(self.blocks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _combine(self):
|
||||||
|
ret = []
|
||||||
|
for block in self.blocks:
|
||||||
|
ret.extend(block.chat_data)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def _execute_extract_operations(self):
|
||||||
|
return (
|
||||||
|
self._ready_blocks()
|
||||||
|
._remove_duplicate_head()
|
||||||
|
._set_block_end()
|
||||||
|
._remove_overlap()
|
||||||
|
._download_blocks()
|
||||||
|
._remove_duplicate_tail()
|
||||||
|
._combine()
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract(self):
|
||||||
|
if self.duration == 0:
|
||||||
|
print("video is not archived.")
|
||||||
|
return []
|
||||||
|
data = self._execute_extract_operations()
|
||||||
|
if self.processor is None:
|
||||||
|
return data
|
||||||
|
ret = self.processor.process(
|
||||||
|
[{'video_id': None,
|
||||||
|
'timeout': 1,
|
||||||
|
'chatdata': (action["replayChatItemAction"]["actions"][0] for action in data)}]
|
||||||
|
)
|
||||||
|
self.processor.finalize()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
asyncdl.cancel()
|
||||||
51
pytchat/tool/extract/parser.py
Normal file
51
pytchat/tool/extract/parser.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from ... import config
|
||||||
|
from ... import exceptions
|
||||||
|
|
||||||
|
logger = config.logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(jsn):
|
||||||
|
"""
|
||||||
|
Parse replay chat data.
|
||||||
|
Parameter:
|
||||||
|
----------
|
||||||
|
jsn : dict
|
||||||
|
JSON of replay chat data.
|
||||||
|
Returns:
|
||||||
|
------
|
||||||
|
continuation : str
|
||||||
|
actions : list
|
||||||
|
|
||||||
|
"""
|
||||||
|
if jsn is None:
|
||||||
|
raise ValueError("parameter JSON is None")
|
||||||
|
if jsn['response']['responseContext'].get('errors'):
|
||||||
|
raise exceptions.ResponseContextError(
|
||||||
|
'video_id is invalid or private/deleted.')
|
||||||
|
contents = jsn['response'].get('continuationContents')
|
||||||
|
if contents is None:
|
||||||
|
raise exceptions.NoContents('No chat data.')
|
||||||
|
|
||||||
|
cont = contents['liveChatContinuation']['continuations'][0]
|
||||||
|
if cont is None:
|
||||||
|
raise exceptions.NoContinuation('No Continuation')
|
||||||
|
metadata = cont.get('liveChatReplayContinuationData')
|
||||||
|
if metadata:
|
||||||
|
continuation = metadata.get("continuation")
|
||||||
|
actions = contents['liveChatContinuation'].get('actions')
|
||||||
|
return continuation, actions
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
|
||||||
|
def get_offset(item):
|
||||||
|
return int(item['replayChatItemAction']["videoOffsetTimeMsec"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_id(item):
|
||||||
|
return list((list(item['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0])['item'].values())[0].get('id')
|
||||||
|
|
||||||
|
|
||||||
|
def get_type(item):
|
||||||
|
return list((list(item['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0])['item'].keys())[0]
|
||||||
55
pytchat/tool/extract/patch.py
Normal file
55
pytchat/tool/extract/patch.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from . import parser
|
||||||
|
from . block import Block
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
class Patch(NamedTuple):
|
||||||
|
"""
|
||||||
|
Patch represents chunk of chat data
|
||||||
|
which is fetched by asyncdl.fetch_patch._fetch().
|
||||||
|
"""
|
||||||
|
chats: list = []
|
||||||
|
continuation: str = None
|
||||||
|
first: int = None
|
||||||
|
last: int = None
|
||||||
|
|
||||||
|
|
||||||
|
def fill(block: Block, patch: Patch):
|
||||||
|
block_end = block.end
|
||||||
|
if patch.last < block_end or block.is_last:
|
||||||
|
set_patch(block, patch)
|
||||||
|
return
|
||||||
|
for line in reversed(patch.chats):
|
||||||
|
line_offset = parser.get_offset(line)
|
||||||
|
if line_offset < block_end:
|
||||||
|
break
|
||||||
|
patch.chats.pop()
|
||||||
|
set_patch(block, patch._replace(
|
||||||
|
continuation=None,
|
||||||
|
last=line_offset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
block.remaining = 0
|
||||||
|
block.done = True
|
||||||
|
|
||||||
|
|
||||||
|
def split(parent_block: Block, child_block: Block, patch: Patch):
|
||||||
|
parent_block.during_split = False
|
||||||
|
if patch.first <= parent_block.last:
|
||||||
|
''' When patch overlaps with parent_block,
|
||||||
|
discard this block. '''
|
||||||
|
child_block.continuation = None
|
||||||
|
''' Leave child_block.during_split == True
|
||||||
|
to exclude from during_split sequence. '''
|
||||||
|
return
|
||||||
|
child_block.during_split = False
|
||||||
|
child_block.first = patch.first
|
||||||
|
parent_block.end = patch.first
|
||||||
|
fill(child_block, patch)
|
||||||
|
|
||||||
|
|
||||||
|
def set_patch(block: Block, patch: Patch):
|
||||||
|
block.continuation = patch.continuation
|
||||||
|
block.chat_data.extend(patch.chats)
|
||||||
|
block.last = patch.last
|
||||||
|
block.remaining = block.end - block.last
|
||||||
92
pytchat/tool/extract/worker.py
Normal file
92
pytchat/tool/extract/worker.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from . block import Block
|
||||||
|
from . patch import fill, split
|
||||||
|
from ... paramgen import arcparam
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractWorker:
|
||||||
|
"""
|
||||||
|
ExtractWorker associates a download session with a block.
|
||||||
|
|
||||||
|
When the worker finishes fetching, the block
|
||||||
|
being fetched is splitted and assigned the free worker.
|
||||||
|
|
||||||
|
Parameter
|
||||||
|
----------
|
||||||
|
fetch : func :
|
||||||
|
extract function of asyncdl
|
||||||
|
|
||||||
|
block : Block :
|
||||||
|
Block object that includes chat_data
|
||||||
|
|
||||||
|
blocks : list :
|
||||||
|
List of Block(s)
|
||||||
|
|
||||||
|
video_id : str :
|
||||||
|
|
||||||
|
parent_block : Block :
|
||||||
|
the block from which current block is splitted
|
||||||
|
"""
|
||||||
|
__slots__ = ['block', 'fetch', 'blocks', 'video_id', 'parent_block']
|
||||||
|
|
||||||
|
def __init__(self, fetch, block, blocks, video_id):
|
||||||
|
self.block = block
|
||||||
|
self.fetch = fetch
|
||||||
|
self.blocks = blocks
|
||||||
|
self.video_id = video_id
|
||||||
|
self.parent_block = None
|
||||||
|
|
||||||
|
async def run(self, session):
|
||||||
|
while self.block.continuation:
|
||||||
|
patch = await self.fetch(
|
||||||
|
self.block.continuation, session)
|
||||||
|
if patch.continuation is None:
|
||||||
|
"""TODO : make the worker assigned to the last block
|
||||||
|
to work more than twice as possible.
|
||||||
|
"""
|
||||||
|
break
|
||||||
|
if self.parent_block:
|
||||||
|
split(self.parent_block, self.block, patch)
|
||||||
|
self.parent_block = None
|
||||||
|
else:
|
||||||
|
fill(self.block, patch)
|
||||||
|
if self.block.continuation is None:
|
||||||
|
"""finished fetching this block """
|
||||||
|
self.block.done = True
|
||||||
|
self.block = _search_new_block(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_new_block(worker) -> Block:
|
||||||
|
index, undone_block = _get_undone_block(worker.blocks)
|
||||||
|
if undone_block is None:
|
||||||
|
return Block(continuation=None)
|
||||||
|
mean = (undone_block.last + undone_block.end) / 2
|
||||||
|
continuation = arcparam.getparam(worker.video_id, seektime=mean / 1000)
|
||||||
|
worker.parent_block = undone_block
|
||||||
|
worker.parent_block.during_split = True
|
||||||
|
new_block = Block(
|
||||||
|
end=undone_block.end,
|
||||||
|
chat_data=[],
|
||||||
|
continuation=continuation,
|
||||||
|
during_split=True,
|
||||||
|
is_last=worker.parent_block.is_last)
|
||||||
|
'''swap last block'''
|
||||||
|
if worker.parent_block.is_last:
|
||||||
|
worker.parent_block.is_last = False
|
||||||
|
worker.blocks.insert(index + 1, new_block)
|
||||||
|
return new_block
|
||||||
|
|
||||||
|
|
||||||
|
def _get_undone_block(blocks) -> (int, Block):
|
||||||
|
min_interval_ms = 120000
|
||||||
|
max_remaining = 0
|
||||||
|
undone_block = None
|
||||||
|
index_undone_block = 0
|
||||||
|
for index, block in enumerate(blocks):
|
||||||
|
if block.done or block.during_split:
|
||||||
|
continue
|
||||||
|
remaining = block.remaining
|
||||||
|
if remaining > max_remaining and remaining > min_interval_ms:
|
||||||
|
index_undone_block = index
|
||||||
|
undone_block = block
|
||||||
|
max_remaining = remaining
|
||||||
|
return index_undone_block, undone_block
|
||||||
0
pytchat/tool/mining/__init__.py
Normal file
0
pytchat/tool/mining/__init__.py
Normal file
141
pytchat/tool/mining/asyncdl.py
Normal file
141
pytchat/tool/mining/asyncdl.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from . import parser
|
||||||
|
from . block import Block
|
||||||
|
from . worker import ExtractWorker
|
||||||
|
from . patch import Patch
|
||||||
|
from ... import config
|
||||||
|
from ... paramgen import arcparam_mining as arcparam
|
||||||
|
from concurrent.futures import CancelledError
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
headers = config.headers
|
||||||
|
REPLAY_URL = "https://www.youtube.com/live_chat_replay?continuation="
|
||||||
|
INTERVAL = 1
|
||||||
|
def _split(start, end, count, min_interval_sec = 120):
|
||||||
|
"""
|
||||||
|
Split section from `start` to `end` into `count` pieces,
|
||||||
|
and returns the beginning of each piece.
|
||||||
|
The `count` is adjusted so that the length of each piece
|
||||||
|
is no smaller than `min_interval`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
--------
|
||||||
|
List of the offset of each block's first chat data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not (isinstance(start,int) or isinstance(start,float)) or \
|
||||||
|
not (isinstance(end,int) or isinstance(end,float)):
|
||||||
|
raise ValueError("start/end must be int or float")
|
||||||
|
if not isinstance(count,int):
|
||||||
|
raise ValueError("count must be int")
|
||||||
|
if start>end:
|
||||||
|
raise ValueError("end must be equal to or greater than start.")
|
||||||
|
if count<1:
|
||||||
|
raise ValueError("count must be equal to or greater than 1.")
|
||||||
|
if (end-start)/count < min_interval_sec:
|
||||||
|
count = int((end-start)/min_interval_sec)
|
||||||
|
if count == 0 : count = 1
|
||||||
|
interval= (end-start)/count
|
||||||
|
|
||||||
|
if count == 1:
|
||||||
|
return [start]
|
||||||
|
return sorted( list(set( [int(start + interval*j)
|
||||||
|
for j in range(count) ])))
|
||||||
|
|
||||||
|
def ready_blocks(video_id, duration, div, callback):
|
||||||
|
if div <= 0: raise ValueError
|
||||||
|
|
||||||
|
async def _get_blocks( video_id, duration, div, callback):
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [_create_block(session, video_id, seektime, callback)
|
||||||
|
for seektime in _split(0, duration, div)]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_block(session, video_id, seektime, callback):
|
||||||
|
continuation = arcparam.getparam(video_id, seektime = seektime)
|
||||||
|
url=(f"{REPLAY_URL}{quote(continuation)}&playerOffsetMs="
|
||||||
|
f"{int(seektime*1000)}&hidden=false&pbj=1")
|
||||||
|
async with session.get(url, headers = headers) as resp:
|
||||||
|
chat_json = await resp.text()
|
||||||
|
if chat_json is None:
|
||||||
|
return
|
||||||
|
continuation, actions = parser.parse(json.loads(chat_json)[1])
|
||||||
|
first = seektime
|
||||||
|
seektime += INTERVAL
|
||||||
|
if callback:
|
||||||
|
callback(actions, INTERVAL)
|
||||||
|
return Block(
|
||||||
|
continuation = continuation,
|
||||||
|
chat_data = actions,
|
||||||
|
first = first,
|
||||||
|
last = seektime,
|
||||||
|
seektime = seektime
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
fetch initial blocks.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
blocks = loop.run_until_complete(
|
||||||
|
_get_blocks(video_id, duration, div, callback))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def fetch_patch(callback, blocks, video_id):
|
||||||
|
|
||||||
|
async def _allocate_workers():
|
||||||
|
workers = [
|
||||||
|
ExtractWorker(
|
||||||
|
fetch = _fetch, block = block,
|
||||||
|
blocks = blocks, video_id = video_id
|
||||||
|
)
|
||||||
|
for block in blocks
|
||||||
|
]
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [worker.run(session) for worker in workers]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
async def _fetch(seektime,session) -> Patch:
|
||||||
|
continuation = arcparam.getparam(video_id, seektime = seektime)
|
||||||
|
url=(f"{REPLAY_URL}{quote(continuation)}&playerOffsetMs="
|
||||||
|
f"{int(seektime*1000)}&hidden=false&pbj=1")
|
||||||
|
async with session.get(url,headers = config.headers) as resp:
|
||||||
|
chat_json = await resp.text()
|
||||||
|
actions = []
|
||||||
|
try:
|
||||||
|
if chat_json is None:
|
||||||
|
return Patch()
|
||||||
|
continuation, actions = parser.parse(json.loads(chat_json)[1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if callback:
|
||||||
|
callback(actions, INTERVAL)
|
||||||
|
return Patch(chats = actions, continuation = continuation,
|
||||||
|
seektime = seektime, last = seektime)
|
||||||
|
"""
|
||||||
|
allocate workers and assign blocks.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(_allocate_workers())
|
||||||
|
except CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _shutdown():
|
||||||
|
print("\nshutdown...")
|
||||||
|
tasks = [t for t in asyncio.all_tasks()
|
||||||
|
if t is not asyncio.current_task()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cancel():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(_shutdown())
|
||||||
|
|
||||||
62
pytchat/tool/mining/block.py
Normal file
62
pytchat/tool/mining/block.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from . import parser
|
||||||
|
class Block:
|
||||||
|
"""Block object represents something like a box
|
||||||
|
to join chunk of chatdata.
|
||||||
|
|
||||||
|
Parameter:
|
||||||
|
---------
|
||||||
|
first : int :
|
||||||
|
videoOffsetTimeMs of the first chat_data
|
||||||
|
(chat_data[0])
|
||||||
|
|
||||||
|
last : int :
|
||||||
|
videoOffsetTimeMs of the last chat_data.
|
||||||
|
(chat_data[-1])
|
||||||
|
|
||||||
|
this value increases as fetching chatdata progresses.
|
||||||
|
|
||||||
|
end : int :
|
||||||
|
target videoOffsetTimeMs of last chat data for extract,
|
||||||
|
equals to first videoOffsetTimeMs of next block.
|
||||||
|
when extract worker reaches this offset, stop fetching.
|
||||||
|
|
||||||
|
continuation : str :
|
||||||
|
continuation param of last chat data.
|
||||||
|
|
||||||
|
chat_data : list
|
||||||
|
|
||||||
|
done : bool :
|
||||||
|
whether this block has been fetched.
|
||||||
|
|
||||||
|
remaining : int :
|
||||||
|
remaining data to extract.
|
||||||
|
equals end - last.
|
||||||
|
|
||||||
|
is_last : bool :
|
||||||
|
whether this block is the last one in blocklist.
|
||||||
|
|
||||||
|
during_split : bool :
|
||||||
|
whether this block is in the process of during_split.
|
||||||
|
while True, this block is excluded from duplicate split procedure.
|
||||||
|
|
||||||
|
seektime : float :
|
||||||
|
the last position of this block(seconds) already fetched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ['first','last','end','continuation','chat_data','remaining',
|
||||||
|
'done','is_last','during_split','seektime']
|
||||||
|
|
||||||
|
def __init__(self, first = 0, last = 0, end = 0,
|
||||||
|
continuation = '', chat_data = [], is_last = False,
|
||||||
|
during_split = False, seektime = None):
|
||||||
|
self.first = first
|
||||||
|
self.last = last
|
||||||
|
self.end = end
|
||||||
|
self.continuation = continuation
|
||||||
|
self.chat_data = chat_data
|
||||||
|
self.done = False
|
||||||
|
self.remaining = self.end - self.last
|
||||||
|
self.is_last = is_last
|
||||||
|
self.during_split = during_split
|
||||||
|
self.seektime = seektime
|
||||||
|
|
||||||
73
pytchat/tool/mining/parser.py
Normal file
73
pytchat/tool/mining/parser.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import re
|
||||||
|
from ... import config
|
||||||
|
from ... exceptions import (
|
||||||
|
ResponseContextError,
|
||||||
|
NoContents, NoContinuation)
|
||||||
|
|
||||||
|
logger = config.logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(jsn):
|
||||||
|
"""
|
||||||
|
Parse replay chat data.
|
||||||
|
Parameter:
|
||||||
|
----------
|
||||||
|
jsn : dict
|
||||||
|
JSON of replay chat data.
|
||||||
|
Returns:
|
||||||
|
------
|
||||||
|
continuation : str
|
||||||
|
actions : list
|
||||||
|
|
||||||
|
"""
|
||||||
|
if jsn is None:
|
||||||
|
raise ValueError("parameter JSON is None")
|
||||||
|
if jsn['response']['responseContext'].get('errors'):
|
||||||
|
raise ResponseContextError(
|
||||||
|
'video_id is invalid or private/deleted.')
|
||||||
|
contents = jsn["response"].get('continuationContents')
|
||||||
|
if contents is None:
|
||||||
|
raise NoContents('No chat data.')
|
||||||
|
|
||||||
|
cont = contents['liveChatContinuation']['continuations'][0]
|
||||||
|
if cont is None:
|
||||||
|
raise NoContinuation('No Continuation')
|
||||||
|
metadata = cont.get('liveChatReplayContinuationData')
|
||||||
|
if metadata:
|
||||||
|
continuation = metadata.get("continuation")
|
||||||
|
actions = contents['liveChatContinuation'].get('actions')
|
||||||
|
if continuation:
|
||||||
|
return continuation, [action["replayChatItemAction"]["actions"][0]
|
||||||
|
for action in actions
|
||||||
|
if list(action['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0]['item'].get("liveChatPaidMessageRenderer")
|
||||||
|
or list(action['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0]['item'].get("liveChatPaidStickerRenderer")
|
||||||
|
]
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
|
||||||
|
def get_offset(item):
|
||||||
|
return int(item['replayChatItemAction']["videoOffsetTimeMsec"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_id(item):
|
||||||
|
return list((list(item['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0])['item'].values())[0].get('id')
|
||||||
|
|
||||||
|
|
||||||
|
def get_type(item):
|
||||||
|
return list((list(item['replayChatItemAction']["actions"][0].values()
|
||||||
|
)[0])['item'].keys())[0]
|
||||||
|
|
||||||
|
|
||||||
|
_REGEX_YTINIT = re.compile(
|
||||||
|
"window\\[\"ytInitialData\"\\]\\s*=\\s*({.+?});\\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def extract(text):
|
||||||
|
|
||||||
|
match = re.findall(_REGEX_YTINIT, str(text))
|
||||||
|
if match:
|
||||||
|
return match[0]
|
||||||
|
return None
|
||||||
27
pytchat/tool/mining/patch.py
Normal file
27
pytchat/tool/mining/patch.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from . import parser
|
||||||
|
from . block import Block
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
class Patch(NamedTuple):
|
||||||
|
"""
|
||||||
|
Patch represents chunk of chat data
|
||||||
|
which is fetched by asyncdl.fetch_patch._fetch().
|
||||||
|
"""
|
||||||
|
chats : list = []
|
||||||
|
continuation : str = None
|
||||||
|
seektime : float = None
|
||||||
|
first : int = None
|
||||||
|
last : int = None
|
||||||
|
|
||||||
|
def fill(block:Block, patch:Patch):
|
||||||
|
if patch.last < block.end:
|
||||||
|
set_patch(block, patch)
|
||||||
|
return
|
||||||
|
block.continuation = None
|
||||||
|
|
||||||
|
def set_patch(block:Block, patch:Patch):
|
||||||
|
block.continuation = patch.continuation
|
||||||
|
block.chat_data.extend(patch.chats)
|
||||||
|
block.last = patch.seektime
|
||||||
|
block.seektime = patch.seektime
|
||||||
|
|
||||||
72
pytchat/tool/mining/superchat_miner.py
Normal file
72
pytchat/tool/mining/superchat_miner.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from . import asyncdl
|
||||||
|
from . import parser
|
||||||
|
from .. videoinfo import VideoInfo
|
||||||
|
from ... import config
|
||||||
|
from ... exceptions import InvalidVideoIdException
|
||||||
|
logger = config.logger(__name__)
|
||||||
|
headers=config.headers
|
||||||
|
|
||||||
|
class SuperChatMiner:
|
||||||
|
def __init__(self, video_id, duration, div, callback):
|
||||||
|
if not isinstance(div ,int) or div < 1:
|
||||||
|
raise ValueError('div must be positive integer.')
|
||||||
|
elif div > 10:
|
||||||
|
div = 10
|
||||||
|
if not isinstance(duration ,int) or duration < 1:
|
||||||
|
raise ValueError('duration must be positive integer.')
|
||||||
|
self.video_id = video_id
|
||||||
|
self.duration = duration
|
||||||
|
self.div = div
|
||||||
|
self.callback = callback
|
||||||
|
self.blocks = []
|
||||||
|
|
||||||
|
def _ready_blocks(self):
|
||||||
|
blocks = asyncdl.ready_blocks(
|
||||||
|
self.video_id, self.duration, self.div, self.callback)
|
||||||
|
self.blocks = [block for block in blocks if block is not None]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _set_block_end(self):
|
||||||
|
for i in range(len(self.blocks)-1):
|
||||||
|
self.blocks[i].end = self.blocks[i+1].first
|
||||||
|
self.blocks[-1].end = self.duration
|
||||||
|
self.blocks[-1].is_last =True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _download_blocks(self):
|
||||||
|
asyncdl.fetch_patch(self.callback, self.blocks, self.video_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _combine(self):
|
||||||
|
ret = []
|
||||||
|
for block in self.blocks:
|
||||||
|
ret.extend(block.chat_data)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def extract(self):
|
||||||
|
return (
|
||||||
|
self._ready_blocks()
|
||||||
|
._set_block_end()
|
||||||
|
._download_blocks()
|
||||||
|
._combine()
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract(video_id, div = 1, callback = None, processor = None):
|
||||||
|
duration = 0
|
||||||
|
try:
|
||||||
|
duration = VideoInfo(video_id).get_duration()
|
||||||
|
except InvalidVideoIdException:
|
||||||
|
raise
|
||||||
|
if duration == 0:
|
||||||
|
print("video is live.")
|
||||||
|
return []
|
||||||
|
data = SuperChatMiner(video_id, duration, div, callback).extract()
|
||||||
|
if processor is None:
|
||||||
|
return data
|
||||||
|
return processor.process(
|
||||||
|
[{'video_id':None,'timeout':1,'chatdata' : (action
|
||||||
|
for action in data)}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel():
|
||||||
|
asyncdl.cancel()
|
||||||
45
pytchat/tool/mining/worker.py
Normal file
45
pytchat/tool/mining/worker.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from . import parser
|
||||||
|
from . block import Block
|
||||||
|
from . patch import Patch, fill
|
||||||
|
from ... paramgen import arcparam
|
||||||
|
INTERVAL = 1
|
||||||
|
class ExtractWorker:
|
||||||
|
"""
|
||||||
|
ExtractWorker associates a download session with a block.
|
||||||
|
|
||||||
|
When the worker finishes fetching, the block
|
||||||
|
being fetched is splitted and assigned the free worker.
|
||||||
|
|
||||||
|
Parameter
|
||||||
|
----------
|
||||||
|
fetch : func :
|
||||||
|
extract function of asyncdl
|
||||||
|
|
||||||
|
block : Block :
|
||||||
|
Block object that includes chat_data
|
||||||
|
|
||||||
|
blocks : list :
|
||||||
|
List of Block(s)
|
||||||
|
|
||||||
|
video_id : str :
|
||||||
|
|
||||||
|
parent_block : Block :
|
||||||
|
the block from which current block is splitted
|
||||||
|
"""
|
||||||
|
__slots__ = ['block', 'fetch', 'blocks', 'video_id', 'parent_block']
|
||||||
|
def __init__(self, fetch, block, blocks, video_id ):
|
||||||
|
self.block:Block = block
|
||||||
|
self.fetch = fetch
|
||||||
|
self.blocks:list = blocks
|
||||||
|
self.video_id:str = video_id
|
||||||
|
self.parent_block:Block = None
|
||||||
|
|
||||||
|
async def run(self, session):
|
||||||
|
while self.block.continuation:
|
||||||
|
patch = await self.fetch(
|
||||||
|
self.block.seektime, session)
|
||||||
|
fill(self.block, patch)
|
||||||
|
self.block.seektime += INTERVAL
|
||||||
|
self.block.done = True
|
||||||
|
|
||||||
|
|
||||||
155
pytchat/tool/videoinfo.py
Normal file
155
pytchat/tool/videoinfo.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from .. import config
|
||||||
|
from ..exceptions import InvalidVideoIdException
|
||||||
|
from ..util.extract_video_id import extract_video_id
|
||||||
|
|
||||||
|
headers = config.headers
|
||||||
|
|
||||||
|
pattern = re.compile(r"yt\.setConfig\({'PLAYER_CONFIG': ({.*})}\);")
|
||||||
|
|
||||||
|
item_channel_id = [
|
||||||
|
"videoDetails",
|
||||||
|
"embeddedPlayerOverlayVideoDetailsRenderer",
|
||||||
|
"channelThumbnailEndpoint",
|
||||||
|
"channelThumbnailEndpoint",
|
||||||
|
"urlEndpoint",
|
||||||
|
"urlEndpoint",
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_renderer = [
|
||||||
|
"embedPreview",
|
||||||
|
"thumbnailPreviewRenderer"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_response = [
|
||||||
|
"args",
|
||||||
|
"embedded_player_response"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_author_image = [
|
||||||
|
"videoDetails",
|
||||||
|
"embeddedPlayerOverlayVideoDetailsRenderer",
|
||||||
|
"channelThumbnail",
|
||||||
|
"thumbnails",
|
||||||
|
0,
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_thumbnail = [
|
||||||
|
"defaultThumbnail",
|
||||||
|
"thumbnails",
|
||||||
|
2,
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_channel_name = [
|
||||||
|
"videoDetails",
|
||||||
|
"embeddedPlayerOverlayVideoDetailsRenderer",
|
||||||
|
"expandedRenderer",
|
||||||
|
"embeddedPlayerOverlayVideoDetailsExpandedRenderer",
|
||||||
|
"title",
|
||||||
|
"runs",
|
||||||
|
0,
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
|
||||||
|
item_moving_thumbnail = [
|
||||||
|
"movingThumbnail",
|
||||||
|
"thumbnails",
|
||||||
|
0,
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class VideoInfo:
|
||||||
|
'''
|
||||||
|
VideoInfo object retrieves YouTube video information.
|
||||||
|
|
||||||
|
Parameter
|
||||||
|
---------
|
||||||
|
video_id : str
|
||||||
|
|
||||||
|
Exception
|
||||||
|
---------
|
||||||
|
InvalidVideoIdException :
|
||||||
|
Occurs when video_id does not exist on YouTube.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self, video_id):
|
||||||
|
self.video_id = extract_video_id(video_id)
|
||||||
|
text = self._get_page_text(self.video_id)
|
||||||
|
self._parse(text)
|
||||||
|
|
||||||
|
def _get_page_text(self, video_id):
|
||||||
|
url = f"https://www.youtube.com/embed/{video_id}"
|
||||||
|
resp = requests.get(url, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
def _parse(self, text):
|
||||||
|
result = re.search(pattern, text)
|
||||||
|
res = json.loads(result.group(1))
|
||||||
|
response = self._get_item(res, item_response)
|
||||||
|
if response is None:
|
||||||
|
self._check_video_is_private(res.get("args"))
|
||||||
|
self._renderer = self._get_item(json.loads(response), item_renderer)
|
||||||
|
if self._renderer is None:
|
||||||
|
raise InvalidVideoIdException(
|
||||||
|
f"No renderer found in video_id: [{self.video_id}].")
|
||||||
|
|
||||||
|
def _check_video_is_private(self, args):
|
||||||
|
if args and args.get("video_id"):
|
||||||
|
raise InvalidVideoIdException(
|
||||||
|
f"video_id [{self.video_id}] is private or deleted.")
|
||||||
|
raise InvalidVideoIdException(
|
||||||
|
f"video_id [{self.video_id}] is invalid.")
|
||||||
|
|
||||||
|
def _get_item(self, dict_body, items: list):
|
||||||
|
for item in items:
|
||||||
|
if dict_body is None:
|
||||||
|
break
|
||||||
|
if isinstance(dict_body, dict):
|
||||||
|
dict_body = dict_body.get(item)
|
||||||
|
continue
|
||||||
|
if isinstance(item, int) and \
|
||||||
|
isinstance(dict_body, list) and \
|
||||||
|
len(dict_body) > item:
|
||||||
|
dict_body = dict_body[item]
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return dict_body
|
||||||
|
|
||||||
|
def get_duration(self):
|
||||||
|
duration_seconds = self._renderer.get("videoDurationSeconds")
|
||||||
|
if duration_seconds:
|
||||||
|
'''Fetched value is string, so cast to integer.'''
|
||||||
|
return int(duration_seconds)
|
||||||
|
'''When key is not found, explicitly returns None.'''
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_title(self):
|
||||||
|
if self._renderer.get("title"):
|
||||||
|
return [''.join(run["text"])
|
||||||
|
for run in self._renderer["title"]["runs"]][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_channel_id(self):
|
||||||
|
channel_url = self._get_item(self._renderer, item_channel_id)
|
||||||
|
if channel_url:
|
||||||
|
return channel_url[9:]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_author_image(self):
|
||||||
|
return self._get_item(self._renderer, item_author_image)
|
||||||
|
|
||||||
|
def get_thumbnail(self):
|
||||||
|
return self._get_item(self._renderer, item_thumbnail)
|
||||||
|
|
||||||
|
def get_channel_name(self):
|
||||||
|
return self._get_item(self._renderer, item_channel_name)
|
||||||
|
|
||||||
|
def get_moving_thumbnail(self):
|
||||||
|
return self._get_item(self._renderer, item_moving_thumbnail)
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import requests,json,datetime
|
import requests
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
from .. import config
|
from .. import config
|
||||||
|
|
||||||
def download(url):
|
|
||||||
|
def extract(url):
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
html = _session.get(url, headers=config.headers)
|
html = _session.get(url, headers=config.headers)
|
||||||
with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
||||||
)+'test.json',mode ='w',encoding='utf-8') as f:
|
) + 'test.json', mode='w', encoding='utf-8') as f:
|
||||||
json.dump(html.json(),f,ensure_ascii=False)
|
json.dump(html.json(), f, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def save(data,filename,extention):
|
def save(data, filename, extention):
|
||||||
with open(filename+"_"+(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
with open(filename + "_" + (datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')) + extention,
|
||||||
)+extention,mode ='w',encoding='utf-8') as f:
|
mode='w', encoding='utf-8') as f:
|
||||||
f.writelines(data)
|
f.writelines(data)
|
||||||
|
|||||||
25
pytchat/util/extract_video_id.py
Normal file
25
pytchat/util/extract_video_id.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import re
|
||||||
|
from .. exceptions import InvalidVideoIdException
|
||||||
|
|
||||||
|
|
||||||
|
PATTERN = re.compile(r"((?<=(v|V)/)|(?<=be/)|(?<=(\?|\&)v=)|(?<=embed/))([\w-]+)")
|
||||||
|
YT_VIDEO_ID_LENGTH = 11
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_id(url_or_id: str) -> str:
|
||||||
|
ret = ''
|
||||||
|
if type(url_or_id) != str:
|
||||||
|
raise TypeError(f"{url_or_id}: URL or VideoID must be str, but {type(url_or_id)} is passed.")
|
||||||
|
if len(url_or_id) == YT_VIDEO_ID_LENGTH:
|
||||||
|
return url_or_id
|
||||||
|
match = re.search(PATTERN, url_or_id)
|
||||||
|
if match is None:
|
||||||
|
raise InvalidVideoIdException(url_or_id)
|
||||||
|
try:
|
||||||
|
ret = match.group(4)
|
||||||
|
except IndexError:
|
||||||
|
raise InvalidVideoIdException(url_or_id)
|
||||||
|
|
||||||
|
if ret is None or len(ret) != YT_VIDEO_ID_LENGTH:
|
||||||
|
raise InvalidVideoIdException(url_or_id)
|
||||||
|
return ret
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
aiohttp
|
aiohttp
|
||||||
|
protobuf
|
||||||
pytz
|
pytz
|
||||||
requests
|
requests
|
||||||
urllib3
|
urllib3
|
||||||
64
setup.py
64
setup.py
@@ -1,6 +1,5 @@
|
|||||||
from setuptools import setup, find_packages, Command
|
from setuptools import setup, find_packages, Command
|
||||||
#from codecs import open as open_c
|
from os import path, system, remove, rename, removedirs
|
||||||
from os import path, system, remove, rename
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
package_name = "pytchat"
|
package_name = "pytchat"
|
||||||
@@ -8,19 +7,27 @@ package_name = "pytchat"
|
|||||||
root_dir = path.abspath(path.dirname(__file__))
|
root_dir = path.abspath(path.dirname(__file__))
|
||||||
|
|
||||||
def _requirements():
|
def _requirements():
|
||||||
return [name.rstrip() for name in open(path.join(root_dir, 'requirements.txt')).readlines()]
|
return [name.rstrip()
|
||||||
|
for name in open(path.join(
|
||||||
|
root_dir, 'requirements.txt')).readlines()]
|
||||||
|
|
||||||
def _test_requirements():
|
def _test_requirements():
|
||||||
return [name.rstrip() for name in open(path.join(root_dir, 'requirements_test.txt')).readlines()]
|
return [name.rstrip()
|
||||||
|
for name in open(path.join(
|
||||||
|
root_dir, 'requirements_test.txt')).readlines()]
|
||||||
|
|
||||||
with open(path.join(root_dir, package_name, '__init__.py')) as f:
|
with open(path.join(root_dir, package_name, '__init__.py')) as f:
|
||||||
init_text = f.read()
|
init_text = f.read()
|
||||||
version = re.search(r'__version__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
version = re.search(
|
||||||
license = re.search(r'__license__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
r'__version__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||||
author = re.search(r'__author__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
license = re.search(
|
||||||
author_email = re.search(r'__author_email__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
r'__license__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||||
url = re.search(r'__url__\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 version
|
||||||
assert license
|
assert license
|
||||||
@@ -30,43 +37,36 @@ assert url
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
with open('README.MD', 'r', encoding='utf-8') as f:
|
|
||||||
txt = f.read()
|
|
||||||
|
|
||||||
with open('README1.MD', 'w', encoding='utf-8', newline='\n') as f:
|
|
||||||
f.write(txt)
|
|
||||||
|
|
||||||
remove("README.MD")
|
|
||||||
rename("README1.MD","README.MD")
|
|
||||||
with open('README.md', encoding='utf-8') as f:
|
with open('README.md', encoding='utf-8') as f:
|
||||||
long_description = f.read()
|
long_description = f.read()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
|
||||||
packages=find_packages(),
|
|
||||||
version=version,
|
|
||||||
url=url,
|
|
||||||
author=author,
|
author=author,
|
||||||
author_email=author_email,
|
author_email=author_email,
|
||||||
long_description=long_description,
|
|
||||||
long_description_content_type='text/markdown',
|
|
||||||
license=license,
|
|
||||||
install_requires=_requirements(),
|
|
||||||
tests_require=_test_requirements(),
|
|
||||||
description="a python library for fetching youtube live chat.",
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Natural Language :: Japanese',
|
'Natural Language :: Japanese',
|
||||||
'Development Status :: 4 - Beta',
|
'Development Status :: 4 - Beta',
|
||||||
'Programming Language :: Python',
|
'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',
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
],
|
],
|
||||||
|
description="a python library for fetching youtube live chat.",
|
||||||
|
entry_points=
|
||||||
|
'''
|
||||||
|
[console_scripts]
|
||||||
|
pytchat=pytchat.cli:main
|
||||||
|
''',
|
||||||
|
install_requires=_requirements(),
|
||||||
keywords='youtube livechat asyncio',
|
keywords='youtube livechat asyncio',
|
||||||
|
license=license,
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type='text/markdown',
|
||||||
|
name=package_name,
|
||||||
|
packages=find_packages(exclude=['*log.txt','*tests','*testrun']),
|
||||||
|
url=url,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
import pytest
|
import json
|
||||||
from pytchat.parser.replay import Parser
|
import requests
|
||||||
import pytchat.config as config
|
import pytchat.config as config
|
||||||
import requests, json
|
|
||||||
from pytchat.paramgen import arcparam
|
from pytchat.paramgen import arcparam
|
||||||
|
from pytchat.parser.live import Parser
|
||||||
|
|
||||||
|
|
||||||
def test_arcparam_0(mocker):
|
def test_arcparam_0(mocker):
|
||||||
param = arcparam.get("01234567890")
|
param = arcparam.getparam("01234567890", -1)
|
||||||
assert "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoATAAOABAAEgEUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAXIECAEQAXgA" == param
|
assert param == "op2w0wQmGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09SARgAXICCAE%3D"
|
||||||
|
|
||||||
|
|
||||||
def test_arcparam_1(mocker):
|
def test_arcparam_1(mocker):
|
||||||
param = arcparam.get("01234567890", seektime = 100000)
|
param = arcparam.getparam("01234567890", seektime=100000)
|
||||||
assert "op2w0wR3GjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QogNDbw_QCMAA4AEAASANSHAgAEAAYACAAKg5zdGF0aWNjaGVja3N1bUAAWANgAWgBcgQIARABeAA%3D" == param
|
assert param == "op2w0wQtGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09KIDQ28P0AkgDYAFyAggB"
|
||||||
|
|
||||||
|
|
||||||
def test_arcparam_2(mocker):
|
def test_arcparam_2(mocker):
|
||||||
param = arcparam.get("SsjCnHOk-Sk")
|
param = arcparam.getparam("SsjCnHOk-Sk", seektime=100)
|
||||||
url=f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?continuation={param}&pbj=1"
|
url = f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?continuation={param}&pbj=1"
|
||||||
resp = requests.Session().get(url,headers = config.headers)
|
resp = requests.Session().get(url, headers=config.headers)
|
||||||
jsn = json.loads(resp.text)
|
jsn = json.loads(resp.text)
|
||||||
parser = Parser()
|
parser = Parser(is_replay=True)
|
||||||
_ , chatdata = parser.parse(jsn)
|
contents = parser.get_contents(jsn)
|
||||||
|
_ , chatdata = parser.parse(contents)
|
||||||
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"]
|
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"]
|
||||||
print(test_id)
|
assert test_id == "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w"
|
||||||
assert "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w" == test_id
|
|
||||||
|
def test_arcparam_3(mocker):
|
||||||
|
param = arcparam.getparam("01234567890")
|
||||||
|
assert param == "op2w0wQmGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09SARgAXICCAE%3D"
|
||||||
|
|||||||
41
tests/test_arcparam_mining.py
Normal file
41
tests/test_arcparam_mining.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from pytchat.tool.mining import parser
|
||||||
|
import pytchat.config as config
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from pytchat.paramgen import arcparam_mining as arcparam
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcparam_e(mocker):
|
||||||
|
try:
|
||||||
|
arcparam.getparam("01234567890", -1)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcparam_0(mocker):
|
||||||
|
param = arcparam.getparam("01234567890", 0)
|
||||||
|
|
||||||
|
assert param == "op2w0wQsGiBDZzhhRFFvTE1ERXlNelExTmpjNE9UQWdBUSUzRCUzREABYARyAggBeAE%3D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcparam_1(mocker):
|
||||||
|
param = arcparam.getparam("01234567890", seektime=100000)
|
||||||
|
print(param)
|
||||||
|
assert param == "op2w0wQzGiBDZzhhRFFvTE1ERXlNelExTmpjNE9UQWdBUSUzRCUzREABWgUQgMLXL2AEcgIIAXgB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcparam_2(mocker):
|
||||||
|
param = arcparam.getparam("PZz9NB0-Z64", 1)
|
||||||
|
url = f"https://www.youtube.com/live_chat_replay?continuation={param}&playerOffsetMs=1000&pbj=1"
|
||||||
|
resp = requests.Session().get(url, headers=config.headers)
|
||||||
|
jsn = json.loads(resp.text)
|
||||||
|
_, chatdata = parser.parse(jsn[1])
|
||||||
|
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatPaidMessageRenderer"]["id"]
|
||||||
|
print(test_id)
|
||||||
|
assert test_id == "ChwKGkNKSGE0YnFJeWVBQ0ZWcUF3Z0VkdGIwRm9R"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcparam_3(mocker):
|
||||||
|
param = arcparam.getparam("01234567890")
|
||||||
|
assert param == "op2w0wQsGiBDZzhhRFFvTE1ERXlNelExTmpjNE9UQWdBUSUzRCUzREABYARyAggBeAE%3D"
|
||||||
140
tests/test_calculator_get_item.py
Normal file
140
tests/test_calculator_get_item.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from pytchat.processors.superchat.calculator import SuperchatCalculator
|
||||||
|
|
||||||
|
get_item = SuperchatCalculator()._get_item
|
||||||
|
|
||||||
|
dict_test = {
|
||||||
|
'root':{
|
||||||
|
'node0' : 'value0',
|
||||||
|
'node1' : 'value1',
|
||||||
|
'node2' : {
|
||||||
|
'node2-0' : 'value2-0'
|
||||||
|
},
|
||||||
|
|
||||||
|
'node3' : [
|
||||||
|
{'node3-0' : 'value3-0'},
|
||||||
|
{'node3-1' :
|
||||||
|
{'node3-1-0' : 'value3-1-0'}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'node4' : [],
|
||||||
|
'node5' : [
|
||||||
|
[
|
||||||
|
{'node5-1-0' : 'value5-1-0'},
|
||||||
|
{'node5-1-1' : 'value5-1-1'},
|
||||||
|
],
|
||||||
|
{'node5-0' : 'value5-0'},
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items_test0 = [
|
||||||
|
'root',
|
||||||
|
'node1'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
items_test_not_found0 = [
|
||||||
|
'root',
|
||||||
|
'other_data'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
items_test_nest = [
|
||||||
|
'root',
|
||||||
|
'node2',
|
||||||
|
'node2-0'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list0 = [
|
||||||
|
'root',
|
||||||
|
'node3',
|
||||||
|
1,
|
||||||
|
'node3-1'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list1 = [
|
||||||
|
'root',
|
||||||
|
'node3',
|
||||||
|
1,
|
||||||
|
'node3-1',
|
||||||
|
'node3-1-0'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list2 = [
|
||||||
|
'root',
|
||||||
|
'node4',
|
||||||
|
None
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list3 = [
|
||||||
|
'root',
|
||||||
|
'node4'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list_nest = [
|
||||||
|
'root',
|
||||||
|
'node5',
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
'node5-1-1'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_list_nest_not_found1 = [
|
||||||
|
'root',
|
||||||
|
'node5',
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
'node5-1-1',
|
||||||
|
'nodez'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_not_found1 = [
|
||||||
|
'root',
|
||||||
|
'node3',
|
||||||
|
2,
|
||||||
|
'node3-1',
|
||||||
|
'node3-1-0'
|
||||||
|
]
|
||||||
|
|
||||||
|
items_test_not_found2 = [
|
||||||
|
'root',
|
||||||
|
'node3',
|
||||||
|
2,
|
||||||
|
'node3-1',
|
||||||
|
'node3-1-0',
|
||||||
|
'nodex'
|
||||||
|
]
|
||||||
|
def test_get_items_0():
|
||||||
|
assert get_item(dict_test, items_test0) == 'value1'
|
||||||
|
|
||||||
|
def test_get_items_1():
|
||||||
|
assert get_item(dict_test, items_test_not_found0) is None
|
||||||
|
|
||||||
|
def test_get_items_2():
|
||||||
|
assert get_item(dict_test, items_test_nest) == 'value2-0'
|
||||||
|
|
||||||
|
def test_get_items_3():
|
||||||
|
assert get_item(
|
||||||
|
dict_test, items_test_list0) == {'node3-1-0' : 'value3-1-0'}
|
||||||
|
|
||||||
|
def test_get_items_4():
|
||||||
|
assert get_item(dict_test, items_test_list1) == 'value3-1-0'
|
||||||
|
|
||||||
|
def test_get_items_5():
|
||||||
|
assert get_item(dict_test, items_test_not_found1) == None
|
||||||
|
|
||||||
|
def test_get_items_6():
|
||||||
|
assert get_item(dict_test, items_test_not_found2) == None
|
||||||
|
|
||||||
|
def test_get_items_7():
|
||||||
|
assert get_item(dict_test, items_test_list2) == None
|
||||||
|
|
||||||
|
def test_get_items_8():
|
||||||
|
assert get_item(dict_test, items_test_list_nest) == 'value5-1-1'
|
||||||
|
|
||||||
|
def test_get_items_9():
|
||||||
|
assert get_item(dict_test, items_test_list_nest_not_found1) == None
|
||||||
|
|
||||||
|
def test_get_items_10():
|
||||||
|
assert get_item(dict_test, items_test_list3) == []
|
||||||
68
tests/test_calculator_parse.py
Normal file
68
tests/test_calculator_parse.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
from pytchat.parser.live import Parser
|
||||||
|
from pytchat.processors.superchat.calculator import SuperchatCalculator
|
||||||
|
from pytchat.exceptions import ChatParseException
|
||||||
|
parse = SuperchatCalculator()._parse
|
||||||
|
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def load_chatdata(filepath):
|
||||||
|
parser = Parser(is_replay=True)
|
||||||
|
#print(json.loads(_open_file(filepath)))
|
||||||
|
contents = parser.get_contents( json.loads(_open_file(filepath)))
|
||||||
|
return parser.parse(contents)[1]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_1():
|
||||||
|
renderer ={"purchaseAmountText":{"simpleText":"¥2,000"}}
|
||||||
|
symbol ,amount = parse(renderer)
|
||||||
|
assert symbol == '¥'
|
||||||
|
assert amount == 2000.0
|
||||||
|
|
||||||
|
def test_parse_2():
|
||||||
|
renderer ={"purchaseAmountText":{"simpleText":"ABC\x0a200"}}
|
||||||
|
symbol ,amount = parse(renderer)
|
||||||
|
assert symbol == 'ABC\x0a'
|
||||||
|
assert amount == 200.0
|
||||||
|
|
||||||
|
def test_process_0():
|
||||||
|
"""
|
||||||
|
parse superchat data
|
||||||
|
"""
|
||||||
|
chat_component = {
|
||||||
|
'video_id':'',
|
||||||
|
'timeout':10,
|
||||||
|
'chatdata':load_chatdata(r"tests/testdata/calculator/superchat_0.json")
|
||||||
|
}
|
||||||
|
assert SuperchatCalculator().process([chat_component])=={'¥': 6800.0, '€': 2.0}
|
||||||
|
|
||||||
|
def test_process_1():
|
||||||
|
"""
|
||||||
|
parse no superchat data
|
||||||
|
"""
|
||||||
|
chat_component = {
|
||||||
|
'video_id':'',
|
||||||
|
'timeout':10,
|
||||||
|
'chatdata':load_chatdata(r"tests/testdata/calculator/text_only.json")
|
||||||
|
}
|
||||||
|
assert SuperchatCalculator().process([chat_component])=={}
|
||||||
|
|
||||||
|
def test_process_2():
|
||||||
|
"""
|
||||||
|
try to parse after replay end
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
chat_component = {
|
||||||
|
'video_id':'',
|
||||||
|
'timeout':10,
|
||||||
|
'chatdata':load_chatdata(r"tests/testdata/calculator/replay_end.json")
|
||||||
|
}
|
||||||
|
assert False
|
||||||
|
SuperchatCalculator().process([chat_component])
|
||||||
|
except ChatParseException:
|
||||||
|
assert True
|
||||||
|
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
import asyncio,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 (
|
|
||||||
NoLivechatRendererException,NoYtinitialdataException,
|
|
||||||
ResponseContextError, NoContentsException)
|
|
||||||
|
|
||||||
from pytchat.processors.compatible.renderer.textmessage import LiveChatTextMessageRenderer
|
parser = Parser(is_replay=False)
|
||||||
from pytchat.processors.compatible.renderer.paidmessage import LiveChatPaidMessageRenderer
|
|
||||||
from pytchat.processors.compatible.renderer.paidsticker import LiveChatPaidStickerRenderer
|
|
||||||
from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
|
|
||||||
|
|
||||||
parser = Parser()
|
|
||||||
|
|
||||||
def test_textmessage(mocker):
|
def test_textmessage(mocker):
|
||||||
'''api互換processorのテスト:通常テキストメッセージ'''
|
'''api互換processorのテスト:通常テキストメッセージ'''
|
||||||
@@ -20,36 +11,39 @@ def test_textmessage(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/compatible/textmessage.json")
|
_json = _open_file("tests/testdata/compatible/textmessage.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 7,
|
"timeout": 7,
|
||||||
"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', 'textMessageDetails'
|
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage',
|
||||||
|
'textMessageDetails'
|
||||||
}
|
}
|
||||||
assert ret["items"][0]["authorDetails"].keys() == {
|
assert ret["items"][0]["authorDetails"].keys() == {
|
||||||
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator'
|
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor',
|
||||||
|
'isChatModerator'
|
||||||
}
|
}
|
||||||
assert ret["items"][0]["snippet"]["textMessageDetails"].keys() == {
|
assert ret["items"][0]["snippet"]["textMessageDetails"].keys() == {
|
||||||
'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のテスト:メンバ新規登録'''
|
||||||
@@ -57,34 +51,72 @@ def test_newsponcer(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/compatible/newSponsor.json")
|
_json = _open_file("tests/testdata/compatible/newSponsor.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 7,
|
"timeout": 7,
|
||||||
"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'
|
||||||
|
|
||||||
}
|
}
|
||||||
assert ret["items"][0]["authorDetails"].keys() == {
|
assert ret["items"][0]["authorDetails"].keys() == {
|
||||||
'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):
|
||||||
@@ -93,38 +125,56 @@ def test_superchat(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/compatible/superchat.json")
|
_json = _open_file("tests/testdata/compatible/superchat.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 7,
|
"timeout": 7,
|
||||||
"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', 'superChatDetails'
|
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage',
|
||||||
|
'superChatDetails'
|
||||||
}
|
}
|
||||||
assert ret["items"][0]["authorDetails"].keys() == {
|
assert ret["items"][0]["authorDetails"].keys() == {
|
||||||
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor', 'isChatModerator'
|
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor',
|
||||||
|
'isChatModerator'
|
||||||
}
|
}
|
||||||
assert ret["items"][0]["snippet"]["superChatDetails"].keys() == {
|
assert ret["items"][0]["snippet"]["superChatDetails"].keys() == {
|
||||||
'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):
|
||||||
|
processor = CompatibleProcessor()
|
||||||
|
|
||||||
|
_json = _open_file("tests/testdata/unregistered_currency.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
ret = processor.process([data])
|
||||||
|
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()
|
||||||
|
|||||||
228
tests/test_default_processor.py
Normal file
228
tests/test_default_processor.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
from pytchat.parser.live import Parser
|
||||||
|
from pytchat.processors.default.processor import DefaultProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def test_textmessage(mocker):
|
||||||
|
'''text message'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
_json = _open_file("tests/testdata/default/textmessage.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
assert ret.chattype == "textMessage"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == "dummy_message"
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == ""
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is False
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_textmessage_replay_member(mocker):
|
||||||
|
'''text message replay member'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=True)
|
||||||
|
_json = _open_file("tests/testdata/default/replay_member_text.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
assert ret.chattype == "textMessage"
|
||||||
|
assert ret.type == "textMessage"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == "dummy_message"
|
||||||
|
assert ret.messageEx == ["dummy_message"]
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.elapsedTime == "1:23:45"
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == "https://yt3.ggpht.com/X=s16-c-k"
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is True
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_superchat(mocker):
|
||||||
|
'''superchat'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
_json = _open_file("tests/testdata/default/superchat.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
print(json.dumps(chatdata, ensure_ascii=False))
|
||||||
|
assert ret.chattype == "superChat"
|
||||||
|
assert ret.type == "superChat"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == "dummy_message"
|
||||||
|
assert ret.messageEx == ["dummy_message"]
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.elapsedTime == ""
|
||||||
|
assert ret.amountValue == 800
|
||||||
|
assert ret.amountString == "¥800"
|
||||||
|
assert ret.currency == "JPY"
|
||||||
|
assert ret.bgColor == 4280150454
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == ""
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is False
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
assert ret.colors.headerBackgroundColor == 4278239141
|
||||||
|
assert ret.colors.headerTextColor == 4278190080
|
||||||
|
assert ret.colors.bodyBackgroundColor == 4280150454
|
||||||
|
assert ret.colors.bodyTextColor == 4278190080
|
||||||
|
assert ret.colors.authorNameTextColor == 2315255808
|
||||||
|
assert ret.colors.timestampColor == 2147483648
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersticker(mocker):
|
||||||
|
'''supersticker'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
_json = _open_file("tests/testdata/default/supersticker.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
print(json.dumps(chatdata, ensure_ascii=False))
|
||||||
|
assert ret.chattype == "superSticker"
|
||||||
|
assert ret.type == "superSticker"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == ""
|
||||||
|
assert ret.messageEx == []
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.elapsedTime == ""
|
||||||
|
assert ret.amountValue == 200
|
||||||
|
assert ret.amountString == "¥200"
|
||||||
|
assert ret.currency == "JPY"
|
||||||
|
assert ret.bgColor == 4278248959
|
||||||
|
assert ret.sticker == "https://lh3.googleusercontent.com/param_s=s72-rp"
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == ""
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is False
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
assert ret.colors.backgroundColor == 4278237396
|
||||||
|
assert ret.colors.moneyChipBackgroundColor == 4278248959
|
||||||
|
assert ret.colors.moneyChipTextColor == 4278190080
|
||||||
|
assert ret.colors.authorNameTextColor == 3003121664
|
||||||
|
|
||||||
|
|
||||||
|
def test_sponsor(mocker):
|
||||||
|
'''sponsor(membership)'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
_json = _open_file("tests/testdata/default/newSponsor_current.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
print(json.dumps(chatdata, ensure_ascii=False))
|
||||||
|
assert ret.chattype == "newSponsor"
|
||||||
|
assert ret.type == "newSponsor"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == "新規メンバー"
|
||||||
|
assert ret.messageEx == ["新規メンバー"]
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.elapsedTime == ""
|
||||||
|
assert ret.bgColor == 0
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == "https://yt3.ggpht.com/X=s32-c-k"
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is True
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sponsor_legacy(mocker):
|
||||||
|
'''lagacy sponsor(membership)'''
|
||||||
|
processor = DefaultProcessor()
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
_json = _open_file("tests/testdata/default/newSponsor_lagacy.json")
|
||||||
|
|
||||||
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
|
data = {
|
||||||
|
"video_id": "",
|
||||||
|
"timeout": 7,
|
||||||
|
"chatdata": chatdata
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = processor.process([data]).items[0]
|
||||||
|
print(json.dumps(chatdata, ensure_ascii=False))
|
||||||
|
assert ret.chattype == "newSponsor"
|
||||||
|
assert ret.type == "newSponsor"
|
||||||
|
assert ret.id == "dummy_id"
|
||||||
|
assert ret.message == "新規メンバー / ようこそ、author_name!"
|
||||||
|
assert ret.messageEx == ["新規メンバー / ようこそ、author_name!"]
|
||||||
|
assert ret.timestamp == 1570678496000
|
||||||
|
assert ret.datetime == "2019-10-10 12:34:56"
|
||||||
|
assert ret.elapsedTime == ""
|
||||||
|
assert ret.bgColor == 0
|
||||||
|
assert ret.author.name == "author_name"
|
||||||
|
assert ret.author.channelId == "author_channel_id"
|
||||||
|
assert ret.author.channelUrl == "http://www.youtube.com/channel/author_channel_id"
|
||||||
|
assert ret.author.imageUrl == "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"
|
||||||
|
assert ret.author.badgeUrl == ""
|
||||||
|
assert ret.author.isVerified is False
|
||||||
|
assert ret.author.isChatOwner is False
|
||||||
|
assert ret.author.isChatSponsor is True
|
||||||
|
assert ret.author.isChatModerator is False
|
||||||
|
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path, mode='r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
77
tests/test_extract_asyncdl.py
Normal file
77
tests/test_extract_asyncdl.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pytchat.tool.extract import parser
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from aioresponses import aioresponses
|
||||||
|
from concurrent.futures import CancelledError
|
||||||
|
from pytchat.tool.extract import asyncdl
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_asyncdl_split():
|
||||||
|
|
||||||
|
ret = asyncdl._split(0,1000,1)
|
||||||
|
assert ret == [0]
|
||||||
|
|
||||||
|
ret = asyncdl._split(1000,1000,10)
|
||||||
|
assert ret == [1000]
|
||||||
|
|
||||||
|
ret = asyncdl._split(0,1000,5)
|
||||||
|
assert ret == [0,200,400,600,800]
|
||||||
|
|
||||||
|
ret = asyncdl._split(10.5, 700.3, 5)
|
||||||
|
assert ret == [10, 148, 286, 424, 562]
|
||||||
|
|
||||||
|
|
||||||
|
ret = asyncdl._split(0,500,5)
|
||||||
|
assert ret == [0,125,250,375]
|
||||||
|
|
||||||
|
ret = asyncdl._split(0,500,500)
|
||||||
|
assert ret == [0,125,250,375]
|
||||||
|
|
||||||
|
ret = asyncdl._split(-1,1000,5)
|
||||||
|
assert ret == [-1, 199, 399, 599, 799]
|
||||||
|
|
||||||
|
"""invalid argument order"""
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split(500,0,5)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
"""invalid count"""
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split(0,500,-1)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split(0,500,0)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
"""invalid argument type"""
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split(0,5000,5.2)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split(0,5000,"test")
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = asyncdl._split([0,1],5000,5)
|
||||||
|
assert False
|
||||||
|
except ValueError:
|
||||||
|
assert True
|
||||||
128
tests/test_extract_duplcheck.py
Normal file
128
tests/test_extract_duplcheck.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os, sys
|
||||||
|
import time
|
||||||
|
from pytchat.tool.extract import duplcheck
|
||||||
|
from pytchat.tool.extract import parser
|
||||||
|
from pytchat.tool.extract.block import Block
|
||||||
|
from pytchat.tool.extract.duplcheck import _dump
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlap():
|
||||||
|
"""
|
||||||
|
test overlap data
|
||||||
|
operation : [0] [2] [3] [4] -> last :align to end
|
||||||
|
[1] , [5] -> no change
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def load_chatdata(filename):
|
||||||
|
return parser.parse(
|
||||||
|
json.loads(_open_file("tests/testdata/extract_duplcheck/overlap/"+filename))
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
blocks = (
|
||||||
|
Block(first = 0, last= 12771, end= 9890,chat_data = load_chatdata("dp0-0.json")),
|
||||||
|
Block(first = 9890, last= 15800, end= 20244,chat_data = load_chatdata("dp0-1.json")),
|
||||||
|
Block(first = 20244,last= 45146, end= 32476,chat_data = load_chatdata("dp0-2.json")),
|
||||||
|
Block(first = 32476,last= 50520, end= 41380,chat_data = load_chatdata("dp0-3.json")),
|
||||||
|
Block(first = 41380,last= 62875, end= 52568,chat_data = load_chatdata("dp0-4.json")),
|
||||||
|
Block(first = 52568,last= 62875, end= 54000,chat_data = load_chatdata("dp0-5.json"),is_last=True)
|
||||||
|
)
|
||||||
|
result = duplcheck.remove_overlap(blocks)
|
||||||
|
#dp0-0.json has item offset time is 9890 (equals block[0].end = block[1].first),
|
||||||
|
#but must be aligne to the most close and smaller value:9779.
|
||||||
|
assert result[0].last == 9779
|
||||||
|
|
||||||
|
assert result[1].last == 15800
|
||||||
|
|
||||||
|
assert result[2].last == 32196
|
||||||
|
|
||||||
|
assert result[3].last == 41116
|
||||||
|
|
||||||
|
assert result[4].last == 52384
|
||||||
|
|
||||||
|
#the last block must be always added to result.
|
||||||
|
assert result[5].last == 62875
|
||||||
|
|
||||||
|
def test_duplicate_head():
|
||||||
|
|
||||||
|
def load_chatdata(filename):
|
||||||
|
return parser.parse(
|
||||||
|
json.loads(_open_file("tests/testdata/extract_duplcheck/head/"+filename))
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
"""
|
||||||
|
test duplicate head data
|
||||||
|
operation : [0] , [1] -> discard [0]
|
||||||
|
[1] , [2] -> discard [1]
|
||||||
|
[2] , [3] -> append [2]
|
||||||
|
[3] , [4] -> discard [3]
|
||||||
|
[4] , [5] -> append [4]
|
||||||
|
append [5]
|
||||||
|
|
||||||
|
result : [2] , [4] , [5]
|
||||||
|
"""
|
||||||
|
|
||||||
|
#chat data offsets are ignored.
|
||||||
|
blocks = (
|
||||||
|
Block(first = 0, last = 2500, chat_data = load_chatdata("dp0-0.json")),
|
||||||
|
Block(first = 0, last =38771, chat_data = load_chatdata("dp0-1.json")),
|
||||||
|
Block(first = 0, last =45146, chat_data = load_chatdata("dp0-2.json")),
|
||||||
|
Block(first = 20244, last =60520, chat_data = load_chatdata("dp0-3.json")),
|
||||||
|
Block(first = 20244, last =62875, chat_data = load_chatdata("dp0-4.json")),
|
||||||
|
Block(first = 52568, last =62875, chat_data = load_chatdata("dp0-5.json"))
|
||||||
|
)
|
||||||
|
_dump(blocks)
|
||||||
|
result = duplcheck.remove_duplicate_head(blocks)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].first == blocks[2].first
|
||||||
|
assert result[0].last == blocks[2].last
|
||||||
|
assert result[1].first == blocks[4].first
|
||||||
|
assert result[1].last == blocks[4].last
|
||||||
|
assert result[2].first == blocks[5].first
|
||||||
|
assert result[2].last == blocks[5].last
|
||||||
|
|
||||||
|
def test_duplicate_tail():
|
||||||
|
"""
|
||||||
|
test duplicate tail data
|
||||||
|
operation : append [0]
|
||||||
|
[0] , [1] -> discard [1]
|
||||||
|
[1] , [2] -> append [2]
|
||||||
|
[2] , [3] -> discard [3]
|
||||||
|
[3] , [4] -> append [4]
|
||||||
|
[4] , [5] -> discard [5]
|
||||||
|
|
||||||
|
result : [0] , [2] , [4]
|
||||||
|
"""
|
||||||
|
def load_chatdata(filename):
|
||||||
|
return parser.parse(
|
||||||
|
json.loads(_open_file("tests/testdata/extract_duplcheck/head/"+filename))
|
||||||
|
)[1]
|
||||||
|
#chat data offsets are ignored.
|
||||||
|
blocks = (
|
||||||
|
Block(first = 0,last = 2500, chat_data=load_chatdata("dp0-0.json")),
|
||||||
|
Block(first = 1500,last = 2500, chat_data=load_chatdata("dp0-1.json")),
|
||||||
|
Block(first = 10000,last = 45146, chat_data=load_chatdata("dp0-2.json")),
|
||||||
|
Block(first = 20244,last = 45146, chat_data=load_chatdata("dp0-3.json")),
|
||||||
|
Block(first = 20244,last = 62875, chat_data=load_chatdata("dp0-4.json")),
|
||||||
|
Block(first = 52568,last = 62875, chat_data=load_chatdata("dp0-5.json"))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = duplcheck.remove_duplicate_tail(blocks)
|
||||||
|
_dump(result)
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].first == blocks[0].first
|
||||||
|
assert result[0].last == blocks[0].last
|
||||||
|
assert result[1].first == blocks[2].first
|
||||||
|
assert result[1].last == blocks[2].last
|
||||||
|
assert result[2].first == blocks[4].first
|
||||||
|
assert result[2].last == blocks[4].last
|
||||||
|
|
||||||
|
|
||||||
238
tests/test_extract_patch.py
Normal file
238
tests/test_extract_patch.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os, sys
|
||||||
|
import time
|
||||||
|
from aioresponses import aioresponses
|
||||||
|
from pytchat.tool.extract import duplcheck
|
||||||
|
from pytchat.tool.extract import parser
|
||||||
|
from pytchat.tool.extract.block import Block
|
||||||
|
from pytchat.tool.extract.patch import Patch, fill, split, set_patch
|
||||||
|
from pytchat.tool.extract.duplcheck import _dump
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def load_chatdata(filename):
|
||||||
|
return parser.parse(
|
||||||
|
json.loads(_open_file("tests/testdata/fetch_patch/"+filename))
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_0():
|
||||||
|
"""
|
||||||
|
Normal case
|
||||||
|
|
||||||
|
~~~~~~ before ~~~~~~
|
||||||
|
|
||||||
|
@parent_block (# = already fetched)
|
||||||
|
|
||||||
|
first last end
|
||||||
|
|########----------------------------------------|
|
||||||
|
|
||||||
|
|
||||||
|
@child_block
|
||||||
|
|
||||||
|
first = last = 0 end (=parent_end)
|
||||||
|
| |
|
||||||
|
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-- patch --|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
|
||||||
|
V
|
||||||
|
|
||||||
|
~~~~~~ after ~~~~~~
|
||||||
|
|
||||||
|
|
||||||
|
@parent_block
|
||||||
|
|
||||||
|
first last end (after split)
|
||||||
|
|########------------|
|
||||||
|
|
||||||
|
@child_block
|
||||||
|
first last end
|
||||||
|
|###########---------------|
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-- patch --|
|
||||||
|
"""
|
||||||
|
parent = Block(first=0, last=4000, end=60000, continuation='parent', during_split=True)
|
||||||
|
child = Block(first=0, last=0, end=60000, continuation='mean', during_split=True)
|
||||||
|
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||||
|
first=32500, last=34000, continuation='patch')
|
||||||
|
|
||||||
|
split(parent,child,patch)
|
||||||
|
|
||||||
|
assert child.continuation == 'patch'
|
||||||
|
assert parent.last < child.first
|
||||||
|
assert parent.end == child.first
|
||||||
|
assert child.first < child.last
|
||||||
|
assert child.last < child.end
|
||||||
|
assert parent.during_split == False
|
||||||
|
assert child.during_split == False
|
||||||
|
|
||||||
|
def test_split_1():
|
||||||
|
"""patch.first <= parent_block.last
|
||||||
|
|
||||||
|
While awaiting at run()->asyncdl._fetch()
|
||||||
|
fetching parent_block proceeds,
|
||||||
|
and parent.block.last exceeds patch.first.
|
||||||
|
|
||||||
|
In this case, fetched patch is all discarded,
|
||||||
|
and worker searches other processing block again.
|
||||||
|
|
||||||
|
~~~~~~ before ~~~~~~
|
||||||
|
|
||||||
|
patch.first
|
||||||
|
first | last end
|
||||||
|
|####################|#####|---------------------|
|
||||||
|
^
|
||||||
|
@child_block
|
||||||
|
first = last = 0 end (=parent_end)
|
||||||
|
| |
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-- patch --|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
|
||||||
|
V
|
||||||
|
|
||||||
|
~~~~~~ after ~~~~~~
|
||||||
|
|
||||||
|
@parent_block
|
||||||
|
first last end
|
||||||
|
|###########################|--------------------|
|
||||||
|
|
||||||
|
@child_block
|
||||||
|
|
||||||
|
.............. -> discard all data
|
||||||
|
|
||||||
|
"""
|
||||||
|
parent = Block(first=0, last=33000, end=60000, continuation='parent', during_split=True)
|
||||||
|
child = Block(first=0, last=0, end=60000, continuation='mean', during_split=True)
|
||||||
|
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||||
|
first=32500, last=34000, continuation='patch')
|
||||||
|
|
||||||
|
split(parent,child,patch)
|
||||||
|
|
||||||
|
assert parent.last == 33000 #no change
|
||||||
|
assert parent.end == 60000 #no change
|
||||||
|
assert child.continuation is None
|
||||||
|
assert parent.during_split == False
|
||||||
|
assert child.during_split == True #exclude during_split sequence
|
||||||
|
|
||||||
|
def test_split_2():
|
||||||
|
"""child_block.end < patch.last:
|
||||||
|
|
||||||
|
Case the last offset of patch exceeds child_block.end.
|
||||||
|
In this case, remove overlapped data of patch.
|
||||||
|
|
||||||
|
~~~~~~ before ~~~~~~
|
||||||
|
|
||||||
|
@parent_block (# = already fetched)
|
||||||
|
first last end (before split)
|
||||||
|
|########------------------------------|
|
||||||
|
|
||||||
|
@child_block
|
||||||
|
first = last = 0 end (=parent_end)
|
||||||
|
| |
|
||||||
|
|
||||||
|
continuation:succeed from patch
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-------- patch --------|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
|
||||||
|
V
|
||||||
|
|
||||||
|
~~~~~~ after ~~~~~~
|
||||||
|
|
||||||
|
@parent_block
|
||||||
|
first last end (after split)
|
||||||
|
|########------------|
|
||||||
|
|
||||||
|
@child_block old patch.end
|
||||||
|
first last=end |
|
||||||
|
|#################|...... cut extra data.
|
||||||
|
^
|
||||||
|
continuation : None (extract complete)
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-------- patch --------|
|
||||||
|
"""
|
||||||
|
parent = Block(first=0, last=4000, end=33500, continuation='parent', during_split=True)
|
||||||
|
child = Block(first=0, last=0, end=33500, continuation='mean', during_split=True)
|
||||||
|
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||||
|
first=32500, last=34000, continuation='patch')
|
||||||
|
|
||||||
|
split(parent,child,patch)
|
||||||
|
|
||||||
|
assert child.continuation is None
|
||||||
|
assert parent.last < child.first
|
||||||
|
assert parent.end == child.first
|
||||||
|
assert child.first < child.last
|
||||||
|
assert child.last < child.end
|
||||||
|
assert child.continuation is None
|
||||||
|
assert parent.during_split == False
|
||||||
|
assert child.during_split == False
|
||||||
|
|
||||||
|
def test_split_none():
|
||||||
|
"""patch.last <= parent_block.last
|
||||||
|
|
||||||
|
While awaiting at run()->asyncdl._fetch()
|
||||||
|
fetching parent_block proceeds,
|
||||||
|
and parent.block.last exceeds patch.first.
|
||||||
|
|
||||||
|
In this case, fetched patch is all discarded,
|
||||||
|
and worker searches other processing block again.
|
||||||
|
|
||||||
|
~~~~~~ before ~~~~~~
|
||||||
|
|
||||||
|
patch.first
|
||||||
|
first | last end
|
||||||
|
|####################|###################|-------|
|
||||||
|
^
|
||||||
|
@child_block
|
||||||
|
first = last = 0 end (=parent_end)
|
||||||
|
| |
|
||||||
|
|
||||||
|
@fetched patch
|
||||||
|
|-- patch --|
|
||||||
|
patch.last < parent_block.last .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
|
||||||
|
V
|
||||||
|
|
||||||
|
~~~~~~ after ~~~~~~
|
||||||
|
|
||||||
|
@parent_block
|
||||||
|
first last end (before split)
|
||||||
|
|########################################|-------|
|
||||||
|
|
||||||
|
@child_block
|
||||||
|
|
||||||
|
............ -> discard all data.
|
||||||
|
|
||||||
|
"""
|
||||||
|
parent = Block(first=0, last=40000, end=60000, continuation='parent', during_split=True)
|
||||||
|
child = Block(first=0, last=0, end=60000, continuation='mean', during_split=True)
|
||||||
|
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||||
|
first=32500, last=34000, continuation='patch')
|
||||||
|
|
||||||
|
split(parent,child,patch)
|
||||||
|
|
||||||
|
assert parent.last == 40000 #no change
|
||||||
|
assert parent.end == 60000 #no change
|
||||||
|
assert child.continuation is None
|
||||||
|
assert parent.during_split == False
|
||||||
|
assert child.during_split == True #exclude during_split sequence
|
||||||
55
tests/test_extract_video_id.py
Normal file
55
tests/test_extract_video_id.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from pytchat.util.extract_video_id import extract_video_id
|
||||||
|
from pytchat.exceptions import InvalidVideoIdException
|
||||||
|
|
||||||
|
VALID_TEST_PATTERNS = (
|
||||||
|
("ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||||
|
("vid_test_be", "vid_test_be"),
|
||||||
|
("https://www.youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||||
|
("https://www.youtube.com/watch?v=123_456_789&t=123s", "123_456_789"),
|
||||||
|
("www.youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||||
|
("watch?v=123_456_789", "123_456_789"),
|
||||||
|
("youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||||
|
("http://youtu.be/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||||
|
("youtu.be/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||||
|
("https://www.youtube.com/watch?v=ABC_EFG_IJK&list=XYZ_ABC_12345&start_radio=1&t=1", "ABC_EFG_IJK"),
|
||||||
|
("https://www.youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||||
|
("www.youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||||
|
("youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK")
|
||||||
|
)
|
||||||
|
|
||||||
|
INVALID_TEST_PATTERNS = (
|
||||||
|
("", ""),
|
||||||
|
("0123456789", "0123456789"), # less than 11 letters id
|
||||||
|
("more_than_11_letter_string", "more_than_11_letter_string"),
|
||||||
|
("https://www.youtube.com/watch?v=more_than_11_letter_string", "more_than_11_letter_string"),
|
||||||
|
("https://www.youtube.com/channel/123_456_789", "123_456_789"),
|
||||||
|
)
|
||||||
|
|
||||||
|
TYPEERROR_TEST_PATTERNS = (
|
||||||
|
(100, 100), # not string
|
||||||
|
(["123_456_789"], "123_456_789"), # not string
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_valid_pattern():
|
||||||
|
for pattern in VALID_TEST_PATTERNS:
|
||||||
|
ret = extract_video_id(pattern[0])
|
||||||
|
assert ret == pattern[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_invalid_pattern():
|
||||||
|
for pattern in INVALID_TEST_PATTERNS:
|
||||||
|
try:
|
||||||
|
extract_video_id(pattern[0])
|
||||||
|
assert False
|
||||||
|
except InvalidVideoIdException:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_typeerror_pattern():
|
||||||
|
for pattern in TYPEERROR_TEST_PATTERNS:
|
||||||
|
try:
|
||||||
|
extract_video_id(pattern[0])
|
||||||
|
assert False
|
||||||
|
except TypeError:
|
||||||
|
assert True
|
||||||
48
tests/test_jsonfile_archiver.py
Normal file
48
tests/test_jsonfile_archiver.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import json
|
||||||
|
from pytchat.processors.jsonfile_archiver import JsonfileArchiver
|
||||||
|
from unittest.mock import patch, mock_open
|
||||||
|
from tests.testdata.jsonfile_archiver.chat_component import chat_component
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_checkpath(mocker):
|
||||||
|
processor = JsonfileArchiver("path")
|
||||||
|
mocker.patch('os.path.exists').side_effect = exists_file
|
||||||
|
'''Test no duplicate file.'''
|
||||||
|
assert processor._checkpath("z:/other.txt") == "z:/other.txt"
|
||||||
|
|
||||||
|
'''Test duplicate filename.
|
||||||
|
The case the name first renamed ('test.txt -> test(0).txt')
|
||||||
|
is also duplicated.
|
||||||
|
'''
|
||||||
|
assert processor._checkpath("z:/test.txt") == "z:/test(1).txt"
|
||||||
|
|
||||||
|
'''Test no extention file (duplicate).'''
|
||||||
|
assert processor._checkpath("z:/test") == "z:/test(0)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_write():
|
||||||
|
'''Test read and write chatdata'''
|
||||||
|
mock = mock_open(read_data = "")
|
||||||
|
with patch('builtins.open',mock):
|
||||||
|
processor = JsonfileArchiver("path")
|
||||||
|
save_path = processor.process([chat_component])
|
||||||
|
fh = mock()
|
||||||
|
actuals = [args[0] for (args, kwargs) in fh.writelines.call_args_list]
|
||||||
|
'''write format is json dump string with 0x0A'''
|
||||||
|
to_be_written = [json.dumps(action, ensure_ascii=False)+'\n'
|
||||||
|
for action in chat_component["chatdata"]]
|
||||||
|
for i in range(len(actuals)):
|
||||||
|
assert actuals[i] == to_be_written[i]
|
||||||
|
assert save_path == {'save_path': 'path', 'total_lines': 7}
|
||||||
|
|
||||||
|
|
||||||
|
def exists_file(path):
|
||||||
|
if path == "z:/test.txt":
|
||||||
|
return True
|
||||||
|
if path == "z:/test(0).txt":
|
||||||
|
return True
|
||||||
|
if path == "z:/test":
|
||||||
|
return True
|
||||||
@@ -1,38 +1,30 @@
|
|||||||
import pytest
|
|
||||||
from pytchat.parser.live import Parser
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import asyncio,aiohttp
|
|
||||||
|
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
from pytchat.core_async.livechat import LiveChatAsync
|
from pytchat.core_async.livechat import LiveChatAsync
|
||||||
from pytchat.exceptions import (
|
from pytchat.exceptions import ResponseContextError
|
||||||
NoLivechatRendererException,NoYtinitialdataException,
|
|
||||||
ResponseContextError,NoContentsException)
|
|
||||||
|
|
||||||
|
|
||||||
from pytchat.core_multithread.livechat import LiveChat
|
|
||||||
import unittest
|
|
||||||
from unittest import TestCase
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
@aioresponses()
|
@aioresponses()
|
||||||
def test_Async(*mock):
|
def test_Async(*mock):
|
||||||
vid=''
|
vid = '__test_id__'
|
||||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||||
_text = json.loads(_text)
|
_text = json.loads(_text)
|
||||||
mock[0].get(f"https://www.youtube.com/live_chat?v={vid}&is_popout=1", status=200, body=_text)
|
mock[0].get(
|
||||||
|
f"https://www.youtube.com/live_chat?v={vid}&is_popout=1", status=200, body=_text)
|
||||||
try:
|
try:
|
||||||
chat = LiveChatAsync(video_id='')
|
chat = LiveChatAsync(video_id='__test_id__')
|
||||||
assert chat.is_alive()
|
assert chat.is_alive()
|
||||||
chat.terminate()
|
chat.terminate()
|
||||||
assert not chat.is_alive()
|
assert not chat.is_alive()
|
||||||
except ResponseContextError:
|
except ResponseContextError:
|
||||||
assert not chat.is_alive()
|
assert not chat.is_alive()
|
||||||
|
|
||||||
|
|
||||||
def test_MultiThread(mocker):
|
def test_MultiThread(mocker):
|
||||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||||
_text = json.loads(_text)
|
_text = json.loads(_text)
|
||||||
@@ -41,13 +33,10 @@ def test_MultiThread(mocker):
|
|||||||
responseMock.text = _text
|
responseMock.text = _text
|
||||||
mocker.patch('requests.Session.get').return_value = responseMock
|
mocker.patch('requests.Session.get').return_value = responseMock
|
||||||
try:
|
try:
|
||||||
chat = LiveChatAsync(video_id='')
|
chat = LiveChatAsync(video_id='__test_id__')
|
||||||
assert chat.is_alive()
|
assert chat.is_alive()
|
||||||
chat.terminate()
|
chat.terminate()
|
||||||
assert not chat.is_alive()
|
assert not chat.is_alive()
|
||||||
except ResponseContextError:
|
except ResponseContextError:
|
||||||
chat.terminate()
|
chat.terminate()
|
||||||
assert not chat.is_alive()
|
assert not chat.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
140
tests/test_livechat_2.py
Normal file
140
tests/test_livechat_2.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from aioresponses import aioresponses
|
||||||
|
from concurrent.futures import CancelledError
|
||||||
|
from pytchat.core_multithread.livechat import LiveChat
|
||||||
|
from pytchat.core_async.livechat import LiveChatAsync
|
||||||
|
from pytchat.processors.dummy_processor import DummyProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path, mode='r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
@aioresponses()
|
||||||
|
def test_async_live_stream(*mock):
|
||||||
|
|
||||||
|
async def test_loop(*mock):
|
||||||
|
pattern = re.compile(
|
||||||
|
r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
|
||||||
|
_text = _open_file('tests/testdata/test_stream.json')
|
||||||
|
mock[0].get(pattern, status=200, body=_text)
|
||||||
|
chat = LiveChatAsync(video_id='__test_id__', processor=DummyProcessor())
|
||||||
|
chats = await chat.get()
|
||||||
|
rawdata = chats[0]["chatdata"]
|
||||||
|
# assert fetching livachat data
|
||||||
|
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTextMessageRenderer"
|
||||||
|
assert list(rawdata[1]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTextMessageRenderer"
|
||||||
|
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPlaceholderItemRenderer"
|
||||||
|
assert list(rawdata[3]["addLiveChatTickerItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTickerPaidMessageItemRenderer"
|
||||||
|
assert list(rawdata[4]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidMessageRenderer"
|
||||||
|
assert list(rawdata[5]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidStickerRenderer"
|
||||||
|
assert list(rawdata[6]["addLiveChatTickerItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTickerSponsorItemRenderer"
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(test_loop(*mock))
|
||||||
|
except CancelledError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
@aioresponses()
|
||||||
|
def test_async_replay_stream(*mock):
|
||||||
|
|
||||||
|
async def test_loop(*mock):
|
||||||
|
pattern_live = re.compile(
|
||||||
|
r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
|
||||||
|
pattern_replay = re.compile(
|
||||||
|
r'^https://www.youtube.com/live_chat_replay/get_live_chat_replay\?continuation=.*$')
|
||||||
|
# empty livechat -> switch to fetch replaychat
|
||||||
|
_text_live = _open_file('tests/testdata/finished_live.json')
|
||||||
|
_text_replay = _open_file('tests/testdata/chatreplay.json')
|
||||||
|
mock[0].get(pattern_live, status=200, body=_text_live)
|
||||||
|
mock[0].get(pattern_replay, status=200, body=_text_replay)
|
||||||
|
|
||||||
|
chat = LiveChatAsync(video_id='__test_id__', processor=DummyProcessor())
|
||||||
|
chats = await chat.get()
|
||||||
|
rawdata = chats[0]["chatdata"]
|
||||||
|
# assert fetching replaychat data
|
||||||
|
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTextMessageRenderer"
|
||||||
|
assert list(rawdata[14]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidMessageRenderer"
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(test_loop(*mock))
|
||||||
|
except CancelledError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
@aioresponses()
|
||||||
|
def test_async_force_replay(*mock):
|
||||||
|
|
||||||
|
async def test_loop(*mock):
|
||||||
|
pattern_live = re.compile(
|
||||||
|
r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
|
||||||
|
pattern_replay = re.compile(
|
||||||
|
r'^https://www.youtube.com/live_chat_replay/get_live_chat_replay\?continuation=.*$')
|
||||||
|
# valid live data, but force_replay = True
|
||||||
|
_text_live = _open_file('tests/testdata/test_stream.json')
|
||||||
|
# valid replay data
|
||||||
|
_text_replay = _open_file('tests/testdata/chatreplay.json')
|
||||||
|
|
||||||
|
mock[0].get(pattern_live, status=200, body=_text_live)
|
||||||
|
mock[0].get(pattern_replay, status=200, body=_text_replay)
|
||||||
|
# force replay
|
||||||
|
chat = LiveChatAsync(
|
||||||
|
video_id='__test_id__', processor=DummyProcessor(), force_replay=True)
|
||||||
|
chats = await chat.get()
|
||||||
|
rawdata = chats[0]["chatdata"]
|
||||||
|
# assert fetching replaychat data
|
||||||
|
assert list(rawdata[14]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidMessageRenderer"
|
||||||
|
# assert not mix livechat data
|
||||||
|
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] != "liveChatPlaceholderItemRenderer"
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(test_loop(*mock))
|
||||||
|
except CancelledError:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_multithread_live_stream(mocker):
|
||||||
|
|
||||||
|
_text = _open_file('tests/testdata/test_stream.json')
|
||||||
|
responseMock = mocker.Mock()
|
||||||
|
responseMock.status_code = 200
|
||||||
|
responseMock.text = _text
|
||||||
|
mocker.patch(
|
||||||
|
'requests.Session.get').return_value.__enter__.return_value = responseMock
|
||||||
|
|
||||||
|
chat = LiveChat(video_id='__test_id__', processor=DummyProcessor())
|
||||||
|
chats = chat.get()
|
||||||
|
rawdata = chats[0]["chatdata"]
|
||||||
|
# assert fetching livachat data
|
||||||
|
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTextMessageRenderer"
|
||||||
|
assert list(rawdata[1]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTextMessageRenderer"
|
||||||
|
assert list(rawdata[2]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPlaceholderItemRenderer"
|
||||||
|
assert list(rawdata[3]["addLiveChatTickerItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTickerPaidMessageItemRenderer"
|
||||||
|
assert list(rawdata[4]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidMessageRenderer"
|
||||||
|
assert list(rawdata[5]["addChatItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatPaidStickerRenderer"
|
||||||
|
assert list(rawdata[6]["addLiveChatTickerItemAction"]["item"].keys())[
|
||||||
|
0] == "liveChatTickerSponsorItemRenderer"
|
||||||
|
chat.terminate()
|
||||||
@@ -4,6 +4,6 @@ from pytchat.paramgen import liveparam
|
|||||||
def test_liveparam_0(mocker):
|
def test_liveparam_0(mocker):
|
||||||
_ts1= 1546268400
|
_ts1= 1546268400
|
||||||
param = liveparam._build("01234567890",
|
param = liveparam._build("01234567890",
|
||||||
*([_ts1*1000000 for i in range(5)]))
|
*([_ts1*1000000 for i in range(5)]), topchat_only=False)
|
||||||
test_param="0ofMyAPiARp8Q2c4S0RRb0xNREV5TXpRMU5qYzRPVEFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5TURFeU16UTFOamM0T1RBbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAuNbVqsrfAjAAOABAAkorCAEQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgLjW1arK3wJYA1CAuNbVqsrfAliAuNbVqsrfAmgBggEECAEQAIgBAKABgLjW1arK3wI%3D"
|
test_param="0ofMyANcGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09KIC41tWqyt8CQAFKC1CAuNbVqsrfAlgDUIC41tWqyt8CWIC41tWqyt8CaAGCAQIIAZoBAKABgLjW1arK3wI%3D"
|
||||||
assert test_param == param
|
assert test_param == param
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import pytest
|
|
||||||
from pytchat.parser.live import Parser
|
from pytchat.parser.live import Parser
|
||||||
import json
|
import json
|
||||||
import asyncio,aiohttp
|
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
from pytchat.exceptions import (
|
from pytchat.exceptions import NoContents
|
||||||
NoLivechatRendererException,NoYtinitialdataException,
|
|
||||||
ResponseContextError, NoContentsException)
|
|
||||||
|
|
||||||
|
|
||||||
def _open_file(path):
|
def _open_file(path):
|
||||||
with open(path,mode ='r',encoding = 'utf-8') as f:
|
with open(path, mode='r', encoding='utf-8') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
parser = Parser()
|
|
||||||
|
|
||||||
|
parser = Parser(is_replay=False)
|
||||||
|
|
||||||
|
|
||||||
@aioresponses()
|
@aioresponses()
|
||||||
def test_finishedlive(*mock):
|
def test_finishedlive(*mock):
|
||||||
@@ -20,12 +19,13 @@ def test_finishedlive(*mock):
|
|||||||
_text = _open_file('tests/testdata/finished_live.json')
|
_text = _open_file('tests/testdata/finished_live.json')
|
||||||
_text = json.loads(_text)
|
_text = json.loads(_text)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parser.parse(_text)
|
parser.parse(parser.get_contents(_text))
|
||||||
assert False
|
assert False
|
||||||
except NoContentsException:
|
except NoContents:
|
||||||
assert True
|
assert True
|
||||||
|
|
||||||
|
|
||||||
@aioresponses()
|
@aioresponses()
|
||||||
def test_parsejson(*mock):
|
def test_parsejson(*mock):
|
||||||
'''jsonを正常にパースできるか'''
|
'''jsonを正常にパースできるか'''
|
||||||
@@ -33,12 +33,13 @@ def test_parsejson(*mock):
|
|||||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||||
_text = json.loads(_text)
|
_text = json.loads(_text)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parser.parse(_text)
|
parser.parse(parser.get_contents(_text))
|
||||||
jsn = _text
|
jsn = _text
|
||||||
timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"]
|
timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"]
|
||||||
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"]
|
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"][
|
||||||
assert 5035 == timeout
|
"continuations"][0]["timedContinuationData"]["continuation"]
|
||||||
assert "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D" == continuation
|
assert timeout == 5035
|
||||||
except:
|
assert continuation == "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D"
|
||||||
assert False
|
except Exception:
|
||||||
|
assert False
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
import asyncio,aiohttp
|
|
||||||
from pytchat.parser.live import Parser
|
from pytchat.parser.live import Parser
|
||||||
from pytchat.processors.compatible.processor import CompatibleProcessor
|
from pytchat.processors.speed.calculator import SpeedCalculator
|
||||||
from pytchat.exceptions import (
|
|
||||||
NoLivechatRendererException,NoYtinitialdataException,
|
|
||||||
ResponseContextError, NoContentsException)
|
|
||||||
|
|
||||||
from pytchat.processors.speed_calculator import SpeedCalculator
|
parser = Parser(is_replay=False)
|
||||||
|
|
||||||
parser = Parser()
|
|
||||||
|
|
||||||
def test_speed_1(mocker):
|
def test_speed_1(mocker):
|
||||||
'''test speed calculation with normal json.
|
'''test speed calculation with normal json.
|
||||||
@@ -21,15 +15,16 @@ def test_speed_1(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/speed/speedtest_normal.json")
|
_json = _open_file("tests/testdata/speed/speedtest_normal.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 10,
|
"timeout": 10,
|
||||||
"chatdata" : chatdata
|
"chatdata": chatdata
|
||||||
}
|
}
|
||||||
ret = processor.process([data])
|
ret = processor.process([data])
|
||||||
assert 30 == ret
|
assert 30 == ret
|
||||||
|
|
||||||
|
|
||||||
def test_speed_2(mocker):
|
def test_speed_2(mocker):
|
||||||
'''test speed calculation with no valid chat data.
|
'''test speed calculation with no valid chat data.
|
||||||
'''
|
'''
|
||||||
@@ -37,15 +32,16 @@ def test_speed_2(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/speed/speedtest_undefined.json")
|
_json = _open_file("tests/testdata/speed/speedtest_undefined.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 10,
|
"timeout": 10,
|
||||||
"chatdata" : chatdata
|
"chatdata": chatdata
|
||||||
}
|
}
|
||||||
ret = processor.process([data])
|
ret = processor.process([data])
|
||||||
assert 0 == ret
|
assert ret == 0
|
||||||
|
|
||||||
|
|
||||||
def test_speed_3(mocker):
|
def test_speed_3(mocker):
|
||||||
'''test speed calculation with empty data.
|
'''test speed calculation with empty data.
|
||||||
'''
|
'''
|
||||||
@@ -53,16 +49,16 @@ def test_speed_3(mocker):
|
|||||||
|
|
||||||
_json = _open_file("tests/testdata/speed/speedtest_empty.json")
|
_json = _open_file("tests/testdata/speed/speedtest_empty.json")
|
||||||
|
|
||||||
_, chatdata = parser.parse(json.loads(_json))
|
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||||
data = {
|
data = {
|
||||||
"video_id" : "",
|
"video_id": "",
|
||||||
"timeout" : 10,
|
"timeout": 10,
|
||||||
"chatdata" : chatdata
|
"chatdata": chatdata
|
||||||
}
|
}
|
||||||
ret = processor.process([data])
|
ret = processor.process([data])
|
||||||
assert 0 == ret
|
assert ret == 0
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
66
tests/test_videoinfo.py
Normal file
66
tests/test_videoinfo.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from pytchat.tool.videoinfo import VideoInfo
|
||||||
|
from pytchat.exceptions import InvalidVideoIdException
|
||||||
|
|
||||||
|
|
||||||
|
def _open_file(path):
|
||||||
|
with open(path, mode='r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_test_data(filepath, mocker):
|
||||||
|
_text = _open_file(filepath)
|
||||||
|
response_mock = mocker.Mock()
|
||||||
|
response_mock.status_code = 200
|
||||||
|
response_mock.text = _text
|
||||||
|
mocker.patch('requests.get').return_value = response_mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_page(mocker):
|
||||||
|
_set_test_data('tests/testdata/videoinfo/archived_page.txt', mocker)
|
||||||
|
info = VideoInfo('__test_id__')
|
||||||
|
actual_thumbnail_url = 'https://i.ytimg.com/vi/fzI9FNjXQ0o/hqdefault.jpg'
|
||||||
|
assert info.video_id == '__test_id__'
|
||||||
|
assert info.get_channel_name() == 'GitHub'
|
||||||
|
assert info.get_thumbnail() == actual_thumbnail_url
|
||||||
|
assert info.get_title() == 'GitHub Arctic Code Vault'
|
||||||
|
assert info.get_channel_id() == 'UC7c3Kb6jYCRj4JOHHZTxKsQ'
|
||||||
|
assert info.get_duration() == 148
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_page(mocker):
|
||||||
|
_set_test_data('tests/testdata/videoinfo/live_page.txt', mocker)
|
||||||
|
info = VideoInfo('__test_id__')
|
||||||
|
'''live page :duration = 0'''
|
||||||
|
assert info.get_duration() == 0
|
||||||
|
assert info.video_id == '__test_id__'
|
||||||
|
assert info.get_channel_name() == 'BGM channel'
|
||||||
|
assert info.get_thumbnail() == \
|
||||||
|
'https://i.ytimg.com/vi/fEvM-OUbaKs/hqdefault_live.jpg'
|
||||||
|
assert info.get_title() == (
|
||||||
|
'Coffee Jazz Music - Chill Out Lounge Jazz Music Radio'
|
||||||
|
' - 24/7 Live Stream - Slow Jazz')
|
||||||
|
assert info.get_channel_id() == 'UCQINXHZqCU5i06HzxRkujfg'
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_video_id(mocker):
|
||||||
|
'''Test case invalid video_id is specified.'''
|
||||||
|
_set_test_data(
|
||||||
|
'tests/testdata/videoinfo/invalid_video_id_page.txt', mocker)
|
||||||
|
try:
|
||||||
|
_ = VideoInfo('__test_id__')
|
||||||
|
assert False
|
||||||
|
except InvalidVideoIdException:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_info(mocker):
|
||||||
|
'''Test case the video page has renderer, but no info.'''
|
||||||
|
_set_test_data(
|
||||||
|
'tests/testdata/videoinfo/no_info_page.txt', mocker)
|
||||||
|
info = VideoInfo('__test_id__')
|
||||||
|
assert info.video_id == '__test_id__'
|
||||||
|
assert info.get_channel_name() is None
|
||||||
|
assert info.get_thumbnail() is None
|
||||||
|
assert info.get_title() is None
|
||||||
|
assert info.get_channel_id() is None
|
||||||
|
assert info.get_duration() is None
|
||||||
18
tests/testdata/calculator/replay_end.json
vendored
Normal file
18
tests/testdata/calculator/replay_end.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"response": {
|
||||||
|
"responseContext": {
|
||||||
|
"webResponseContextExtensionData": ""
|
||||||
|
},
|
||||||
|
"continuationContents": {
|
||||||
|
"liveChatContinuation": {
|
||||||
|
"continuations": [
|
||||||
|
{
|
||||||
|
"playerSeekContinuationData": {
|
||||||
|
"continuation": "___reload_continuation___"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3324
tests/testdata/calculator/superchat_0.json
vendored
Normal file
3324
tests/testdata/calculator/superchat_0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
89
tests/testdata/calculator/text_only.json
vendored
Normal file
89
tests/testdata/calculator/text_only.json
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"response": {
|
||||||
|
"responseContext": {
|
||||||
|
"webResponseContextExtensionData": ""
|
||||||
|
},
|
||||||
|
"continuationContents": {
|
||||||
|
"liveChatContinuation": {
|
||||||
|
"continuations": [
|
||||||
|
{
|
||||||
|
"invalidationContinuationData": {
|
||||||
|
"invalidationId": {
|
||||||
|
"objectSource": 1000,
|
||||||
|
"objectId": "___objectId___",
|
||||||
|
"topic": "chat~00000000000~0000000",
|
||||||
|
"subscribeToGcmTopics": true,
|
||||||
|
"protoCreationTimestampMs": "1577804400000"
|
||||||
|
},
|
||||||
|
"timeoutMs": 10000,
|
||||||
|
"continuation": "___continuation___"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"replayChatItemAction": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"addChatItemAction": {
|
||||||
|
"item": {
|
||||||
|
"liveChatTextMessageRenderer": {
|
||||||
|
"message": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"text": "dummy_message"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"authorName": {
|
||||||
|
"simpleText": "author_name"
|
||||||
|
},
|
||||||
|
"authorPhoto": {
|
||||||
|
"thumbnails": [
|
||||||
|
{
|
||||||
|
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||||
|
"width": 64,
|
||||||
|
"height": 64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contextMenuEndpoint": {
|
||||||
|
"commandMetadata": {
|
||||||
|
"webCommandMetadata": {
|
||||||
|
"ignoreNavigation": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liveChatItemContextMenuEndpoint": {
|
||||||
|
"params": "___params___"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "dummy_id",
|
||||||
|
"timestampUsec": 0,
|
||||||
|
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
|
||||||
|
"contextMenuAccessibility": {
|
||||||
|
"accessibilityData": {
|
||||||
|
"label": "コメントの操作"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestampText": {
|
||||||
|
"simpleText": "0:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clientId": "dummy_client_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"videoOffsetTimeMsec": "10000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1823
tests/testdata/compatible/newSponsor_rev.json
vendored
Normal file
1823
tests/testdata/compatible/newSponsor_rev.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user