Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5c7ba52c8 | ||
|
|
c37201fa03 | ||
|
|
a474899268 | ||
|
|
3f72eb0e00 | ||
|
|
4652a56bc6 | ||
|
|
35218a66da | ||
|
|
3432609588 | ||
|
|
48669e5f53 | ||
|
|
f46df3ae42 | ||
|
|
96c028bd5d | ||
|
|
402dc15d7a | ||
|
|
6088ab6932 | ||
|
|
d98d34d8b3 | ||
|
|
24fa104e84 | ||
|
|
b4dad8c641 | ||
|
|
3550cd6d91 | ||
|
|
2815b48e0e | ||
|
|
650e6ccb65 | ||
|
|
4a00a19a43 | ||
|
|
b067eda7b6 | ||
|
|
1b6bc86e76 | ||
|
|
da2b513bcc | ||
|
|
6adae578ef | ||
|
|
128a834841 | ||
|
|
086a14115f | ||
|
|
6a392f3e1a | ||
|
|
93127a703c | ||
|
|
e4ddbaf8ae | ||
|
|
ec75058605 | ||
|
|
2b62e5dc5e | ||
|
|
8d7874096e | ||
|
|
99fcab83c8 | ||
|
|
3027bc0579 | ||
|
|
b1b70a4e76 | ||
|
|
de41341d84 | ||
|
|
a03d43b081 | ||
|
|
f60aaade7f | ||
|
|
d3c34086ff | ||
|
|
6b58c9bcf5 | ||
|
|
c2cba1651e | ||
|
|
ada3eb437d | ||
|
|
c1517d5be8 | ||
|
|
351034d1e6 | ||
|
|
c1db5a0c47 | ||
|
|
088dce712a | ||
|
|
425e880b09 | ||
|
|
62ec78abee | ||
|
|
c84a32682c | ||
|
|
74277b2afe | ||
|
|
cd20b74b2a | ||
|
|
06f54fd985 | ||
|
|
98b0470703 | ||
|
|
bb4113b53c | ||
|
|
07f4382ed4 | ||
|
|
d40720616b | ||
|
|
eebe7c79bd | ||
|
|
6c9e327e36 | ||
|
|
e9161c0ddd | ||
|
|
c8b75dcf0e | ||
|
|
30cb7d7043 | ||
|
|
19d5b74beb | ||
|
|
d5c3e45edc | ||
|
|
1d479fc15c | ||
|
|
20a20ddd08 | ||
|
|
00c239f974 | ||
|
|
67b766b32c | ||
|
|
249aa0d147 | ||
|
|
c708a588d8 | ||
|
|
cb15df525f | ||
|
|
fcddc1516b | ||
|
|
a7732efd07 | ||
|
|
0a2f4e8418 | ||
|
|
0c0ba0dfe6 | ||
|
|
02827b174e | ||
|
|
81dee8a218 | ||
|
|
5eb8bdbd0e | ||
|
|
a37602e666 | ||
|
|
306b69198e | ||
|
|
175e457052 | ||
|
|
5633a48618 | ||
|
|
d7e608e8a1 | ||
|
|
213427fab3 | ||
|
|
3427c6fb69 | ||
|
|
603c4470b7 | ||
|
|
37c8b7ae45 | ||
|
|
d362152c77 | ||
|
|
8f5c3f312a | ||
|
|
15a1d5c210 | ||
|
|
499cf26fa8 | ||
|
|
90596be880 | ||
|
|
50d7b097e6 | ||
|
|
b8d5ec5465 | ||
|
|
3200c5654f | ||
|
|
4905b1e4d8 | ||
|
|
16df63c14e | ||
|
|
e950dff9d2 | ||
|
|
39d99ad4af | ||
|
|
3675c91240 | ||
|
|
46258f625a | ||
|
|
2cc161b589 | ||
|
|
115277e5e1 | ||
|
|
ebf0e7c181 | ||
|
|
b418898eef | ||
|
|
3106b3e545 | ||
|
|
50816a661d | ||
|
|
6755bc8bb2 | ||
|
|
d62e7730ab | ||
|
|
26be989b9b | ||
|
|
73ad0a1f44 | ||
|
|
66b185ebf7 | ||
|
|
8bd82713e2 | ||
|
|
71650c39f7 | ||
|
|
488445c73b | ||
|
|
075e811efe | ||
|
|
9f9b83f185 | ||
|
|
58d9bf7fdb | ||
|
|
b3e6275de7 | ||
|
|
748778f545 | ||
|
|
b2a68d0a74 | ||
|
|
e29b3b8377 | ||
|
|
0859ed5fb1 | ||
|
|
a80d5ba080 | ||
|
|
ac2924824e | ||
|
|
b7e6043a71 | ||
|
|
820ba35013 | ||
|
|
ecd2d130bf | ||
|
|
1d410b6e68 | ||
|
|
f77a2c889b | ||
|
|
47d5ab288f | ||
|
|
5f53fd24dd | ||
|
|
11a9d0e2d7 | ||
|
|
6f18de46f7 | ||
|
|
480c9e15b8 | ||
|
|
35aa7636f6 | ||
|
|
8fee67c2d4 | ||
|
|
74bfdd07e2 | ||
|
|
d3f1643a40 | ||
|
|
eb29f27493 | ||
|
|
8adf75ab83 | ||
|
|
2e05803d75 | ||
|
|
f16c0ee73a | ||
|
|
a338f2b782 | ||
|
|
864ccddfd7 | ||
|
|
339df69e36 | ||
|
|
76a5b0cd18 | ||
|
|
be0ab2431b | ||
|
|
2edb60c592 | ||
|
|
2c6c3a1ca3 | ||
|
|
4be540793d | ||
|
|
08b86fe596 | ||
|
|
157f3b9952 | ||
|
|
8f3ca2662a | ||
|
|
c4b015861c | ||
|
|
3aa413d59e | ||
|
|
03ba285a16 | ||
|
|
5fe0ee5aa8 | ||
|
|
4e829a25d4 | ||
|
|
15132a9bb8 | ||
|
|
64ace9dad6 | ||
|
|
9a2e96d3a0 | ||
|
|
a3695a59b8 | ||
|
|
bc8655ed62 | ||
|
|
3bdc465740 | ||
|
|
235d6b7212 | ||
|
|
9f0754da57 | ||
|
|
306b0a4564 | ||
|
|
1c49387f1a | ||
|
|
300d96e56c | ||
|
|
0e301f48a8 | ||
|
|
a790ab13a9 | ||
|
|
0456300d19 | ||
|
|
2ef1e7028f | ||
|
|
9413c4a186 | ||
|
|
8a8cef399f | ||
|
|
3bcad12cf6 | ||
|
|
4eb18279fe | ||
|
|
e9ed564e1b | ||
|
|
95f975c93d | ||
|
|
8012e1d191 | ||
|
|
f9480ea1eb | ||
|
|
404727c49c | ||
|
|
6b924a88ef | ||
|
|
56294d6a67 | ||
|
|
283443e374 | ||
|
|
89b51c420f | ||
|
|
96474f10c6 | ||
|
|
5f78a99507 | ||
|
|
78373bf45c | ||
|
|
3e11deed8f | ||
|
|
6daa375adf | ||
|
|
497d84015e | ||
|
|
a90bda674d | ||
|
|
48543b7866 | ||
|
|
5d3c7b5abd | ||
|
|
8df7062873 | ||
|
|
b788f692ad | ||
|
|
713215f1d7 | ||
|
|
f16ef60f11 | ||
|
|
9bbdb6c4de | ||
|
|
2200abf204 | ||
|
|
3ed0cb2c35 | ||
|
|
5fa4d051ee | ||
|
|
cd6d522055 | ||
|
|
aa8a4fb592 | ||
|
|
92a01aa4d9 | ||
|
|
dbde072828 | ||
|
|
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 | ||
|
|
56bf721330 | ||
|
|
725af25d81 |
27
.github/workflows/run_test.yml
vendored
Normal file
27
.github/workflows/run_test.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Run All UnitTest
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7, 3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
export PYTHONPATH=./
|
||||
pytest --verbose --color=yes
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 taizan-hokuto
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
157
README.md
157
README.md
@@ -5,9 +5,7 @@ pytchat is a python library for fetching youtube live chat.
|
||||
|
||||
## Description
|
||||
pytchat is a python library for fetching youtube live chat
|
||||
without using youtube api, Selenium or BeautifulSoup.
|
||||
|
||||
pytchatはAPIを使わずにYouTubeチャットを取得するためのpythonライブラリです。
|
||||
without using Selenium or BeautifulSoup.
|
||||
|
||||
Other features:
|
||||
+ Customizable [chat data processors](https://github.com/taizan-hokuto/pytchat/wiki/ChatProcessor) including youtube api compatible one.
|
||||
@@ -16,7 +14,7 @@ Other features:
|
||||
instead of web scraping.
|
||||
|
||||
For more detailed information, see [wiki](https://github.com/taizan-hokuto/pytchat/wiki). <br>
|
||||
より詳細な解説は[wiki](https://github.com/taizan-hokuto/pytchat/wiki/Home_jp)を参照してください。
|
||||
[wiki (Japanese)](https://github.com/taizan-hokuto/pytchat/wiki/Home_jp)
|
||||
|
||||
## Install
|
||||
```python
|
||||
@@ -26,131 +24,61 @@ pip install pytchat
|
||||
|
||||
### CLI
|
||||
|
||||
One-liner command.
|
||||
Save chat data to html.
|
||||
+ One-liner command.
|
||||
|
||||
+ Save chat data to html with embedded custom emojis.
|
||||
|
||||
+ Show chat stream (--echo option).
|
||||
|
||||
```bash
|
||||
$ pytchat -v ZJ6Q4U_Vg6s -o "c:/temp/"
|
||||
|
||||
$ pytchat -v uIx8l2xlYVY -o "c:/temp/"
|
||||
# options:
|
||||
# -v : video_id
|
||||
# -v : Video ID or URL that includes ID
|
||||
# -o : output directory (default path: './')
|
||||
# --echo : Show chats.
|
||||
# saved filename is [video_id].html
|
||||
```
|
||||
|
||||
|
||||
### on-demand mode
|
||||
### Fetch chat data (see [wiki](https://github.com/taizan-hokuto/pytchat/wiki/PytchatCore))
|
||||
```python
|
||||
from pytchat import LiveChat
|
||||
livechat = LiveChat(video_id = "Zvp1pJpie4I")
|
||||
|
||||
while livechat.is_alive():
|
||||
try:
|
||||
chatdata = livechat.get()
|
||||
for c in chatdata.items:
|
||||
print(f"{c.datetime} [{c.author.name}]- {c.message}")
|
||||
chatdata.tick()
|
||||
except KeyboardInterrupt:
|
||||
livechat.terminate()
|
||||
break
|
||||
```
|
||||
|
||||
### callback mode
|
||||
```python
|
||||
from pytchat import LiveChat
|
||||
import time
|
||||
|
||||
def main():
|
||||
livechat = LiveChat(video_id = "Zvp1pJpie4I", callback = disp)
|
||||
while livechat.is_alive():
|
||||
#other background operation.
|
||||
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()
|
||||
|
||||
```
|
||||
|
||||
### asyncio context:
|
||||
```python
|
||||
from pytchat import LiveChatAsync
|
||||
from concurrent.futures import CancelledError
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
livechat = LiveChatAsync("Zvp1pJpie4I", callback = func)
|
||||
while livechat.is_alive():
|
||||
#other background operation.
|
||||
await asyncio.sleep(3)
|
||||
|
||||
#callback function is automatically called.
|
||||
async def func(chatdata):
|
||||
for c in chatdata.items:
|
||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||
await chatdata.tick_async()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
except CancelledError:
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
### youtube api compatible processor:
|
||||
```python
|
||||
from pytchat import LiveChat, CompatibleProcessor
|
||||
import time
|
||||
|
||||
chat = LiveChat("Zvp1pJpie4I",
|
||||
processor = CompatibleProcessor() )
|
||||
|
||||
import pytchat
|
||||
chat = pytchat.create(video_id="uIx8l2xlYVY")
|
||||
while chat.is_alive():
|
||||
try:
|
||||
data = chat.get()
|
||||
polling = data['pollingIntervalMillis']/1000
|
||||
for c in data['items']:
|
||||
if c.get('snippet'):
|
||||
print(f"[{c['authorDetails']['displayName']}]"
|
||||
f"-{c['snippet']['displayMessage']}")
|
||||
time.sleep(polling/len(data['items']))
|
||||
except KeyboardInterrupt:
|
||||
chat.terminate()
|
||||
for c in chat.get().sync_items():
|
||||
print(f"{c.datetime} [{c.author.name}]- {c.message}")
|
||||
```
|
||||
### replay:
|
||||
If specified video is not live,
|
||||
automatically try to fetch archived chat data.
|
||||
|
||||
|
||||
### Output JSON format string (feature of [DefaultProcessor](https://github.com/taizan-hokuto/pytchat/wiki/DefaultProcessor))
|
||||
```python
|
||||
from pytchat import LiveChat
|
||||
import pytchat
|
||||
import time
|
||||
|
||||
def main():
|
||||
#seektime (seconds): start position of chat.
|
||||
chat = LiveChat("ojes5ULOqhc", seektime = 60*30)
|
||||
print('Replay from 30:00')
|
||||
try:
|
||||
while chat.is_alive():
|
||||
data = chat.get()
|
||||
for c in data.items:
|
||||
print(f"{c.elapsedTime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||
data.tick()
|
||||
except KeyboardInterrupt:
|
||||
chat.terminate()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
chat = pytchat.create(video_id="uIx8l2xlYVY")
|
||||
while chat.is_alive():
|
||||
print(chat.get().json())
|
||||
time.sleep(5)
|
||||
'''
|
||||
# Each chat item can also be output in JSON format.
|
||||
for c in chat.get().items:
|
||||
print(c.json())
|
||||
'''
|
||||
```
|
||||
|
||||
|
||||
### other
|
||||
+ Fetch chat with a buffer ([LiveChat](https://github.com/taizan-hokuto/pytchat/wiki/LiveChat))
|
||||
|
||||
+ Use with asyncio ([LiveChatAsync](https://github.com/taizan-hokuto/pytchat/wiki/LiveChatAsync))
|
||||
|
||||
+ YT API compatible chat processor ([CompatibleProcessor](https://github.com/taizan-hokuto/pytchat/wiki/CompatibleProcessor))
|
||||
|
||||
+ Extract archived chat data ([Extractor](https://github.com/taizan-hokuto/pytchat/wiki/Extractor))
|
||||
|
||||
|
||||
## Structure of Default Processor
|
||||
Each item can be got with `items` function.
|
||||
Each item can be got with `sync_items()` function.
|
||||
<table>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
@@ -175,7 +103,7 @@ Each item can be got with `items` function.
|
||||
<tr>
|
||||
<td>messageEx</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>
|
||||
<td>timestamp</td>
|
||||
@@ -284,6 +212,9 @@ Most of source code of CLI refer to:
|
||||
|
||||
[PetterKraabol / Twitch-Chat-Downloader](https://github.com/PetterKraabol/Twitch-Chat-Downloader)
|
||||
|
||||
Progress bar in CLI is based on:
|
||||
|
||||
[vladignatyev/progress.py](https://gist.github.com/vladignatyev/06860ec2040cb497f0f3)
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
"""
|
||||
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'
|
||||
__version__ = '0.0.6.6'
|
||||
__copyright__ = 'Copyright (C) 2019, 2020 taizan-hokuto'
|
||||
__version__ = '0.4.6'
|
||||
__license__ = 'MIT'
|
||||
__author__ = 'taizan-hokuto'
|
||||
__author_email__ = '55448286+taizan-hokuto@users.noreply.github.com'
|
||||
__url__ = 'https://github.com/taizan-hokuto/pytchat'
|
||||
|
||||
__all__ = ["core_async","core_multithread","processors"]
|
||||
|
||||
from .exceptions import (
|
||||
ChatParseException,
|
||||
ResponseContextError,
|
||||
NoContents,
|
||||
NoContinuation,
|
||||
IllegalFunctionCall,
|
||||
InvalidVideoIdException,
|
||||
UnknownConnectionError,
|
||||
RetryExceedMaxCount,
|
||||
ChatDataFinished,
|
||||
ReceivedUnknownContinuation,
|
||||
FailedExtractContinuation,
|
||||
VideoInfoParseError,
|
||||
PatternUnmatchError
|
||||
)
|
||||
|
||||
from .api import (
|
||||
cli,
|
||||
@@ -26,5 +41,7 @@ from .api import (
|
||||
SimpleDisplayProcessor,
|
||||
SpeedCalculator,
|
||||
SuperchatCalculator,
|
||||
VideoInfo
|
||||
)
|
||||
VideoInfo,
|
||||
create
|
||||
)
|
||||
# flake8: noqa
|
||||
@@ -1,5 +1,6 @@
|
||||
from . import cli
|
||||
from . import config
|
||||
from .core import create
|
||||
from .core_multithread.livechat import LiveChat
|
||||
from .core_async.livechat import LiveChatAsync
|
||||
from .processors.chat_processor import ChatProcessor
|
||||
@@ -14,3 +15,25 @@ from .processors.speed.calculator import SpeedCalculator
|
||||
from .processors.superchat.calculator import SuperchatCalculator
|
||||
from .tool.extract.extractor import Extractor
|
||||
from .tool.videoinfo import VideoInfo
|
||||
|
||||
__all__ = [
|
||||
cli,
|
||||
config,
|
||||
LiveChat,
|
||||
LiveChatAsync,
|
||||
ChatProcessor,
|
||||
CompatibleProcessor,
|
||||
DummyProcessor,
|
||||
DefaultProcessor,
|
||||
Extractor,
|
||||
HTMLArchiver,
|
||||
TSVArchiver,
|
||||
JsonfileArchiver,
|
||||
SimpleDisplayProcessor,
|
||||
SpeedCalculator,
|
||||
SuperchatCalculator,
|
||||
VideoInfo,
|
||||
create
|
||||
]
|
||||
|
||||
# flake8: noqa
|
||||
@@ -1,60 +1,71 @@
|
||||
import argparse
|
||||
try:
|
||||
from asyncio import CancelledError
|
||||
except ImportError:
|
||||
from asyncio.futures import CancelledError
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Callable
|
||||
from .arguments import Arguments
|
||||
|
||||
from .. exceptions import InvalidVideoIdException, NoContentsException
|
||||
from .. processors.tsv_archiver import TSVArchiver
|
||||
from .. processors.html_archiver import HTMLArchiver
|
||||
from .. tool.extract.extractor import Extractor
|
||||
from .. tool.videoinfo import VideoInfo
|
||||
from .echo import Echo
|
||||
from .. exceptions import InvalidVideoIdException
|
||||
from .. import __version__
|
||||
from .cli_extractor import CLIExtractor
|
||||
|
||||
|
||||
'''
|
||||
Most of CLI modules refer to
|
||||
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
|
||||
# Arguments
|
||||
parser = argparse.ArgumentParser(description=f'pytchat v{__version__}')
|
||||
parser.add_argument('-v', f'--{Arguments.Name.VIDEO}', type=str,
|
||||
help='Video IDs separated by commas without space.\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='./')
|
||||
help='Output directory (end with "/"). default="./"', default='./')
|
||||
parser.add_argument(f'--{Arguments.Name.DEBUG}', action='store_true',
|
||||
help='Debug mode. Stop when exceptions have occurred and save error data (".dat" file).')
|
||||
parser.add_argument(f'--{Arguments.Name.VERSION}', action='store_true',
|
||||
help='Settings version')
|
||||
help='Show version.')
|
||||
parser.add_argument(f'--{Arguments.Name.ECHO}', action='store_true',
|
||||
help='Display chats of specified video.')
|
||||
|
||||
Arguments(parser.parse_args().__dict__)
|
||||
|
||||
if Arguments().print_version:
|
||||
print(f'pytchat v{__version__}')
|
||||
print(f'pytchat v{__version__} © 2019, 2020 taizan-hokuto')
|
||||
return
|
||||
|
||||
if not Arguments().video_ids:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Echo
|
||||
if Arguments().echo:
|
||||
if len(Arguments().video_ids) > 1:
|
||||
print("When using --echo option, only one video ID can be specified.")
|
||||
return
|
||||
try:
|
||||
Echo(Arguments().video_ids[0]).run()
|
||||
except InvalidVideoIdException as e:
|
||||
print("Invalid video id:", str(e))
|
||||
except Exception as e:
|
||||
print(type(e), str(e))
|
||||
if Arguments().debug:
|
||||
raise
|
||||
finally:
|
||||
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, NoContentsException) as e:
|
||||
print(e)
|
||||
if not os.path.exists(Arguments().output):
|
||||
print("\nThe specified directory does not exist.:{}\n".format(Arguments().output))
|
||||
return
|
||||
parser.print_help()
|
||||
|
||||
def _disp_progress(a,b):
|
||||
print('.',end="",flush=True)
|
||||
try:
|
||||
CLIExtractor().run()
|
||||
except CancelledError as e:
|
||||
print(str(e))
|
||||
|
||||
@@ -2,12 +2,13 @@ from typing import Optional, Dict, Union, List
|
||||
from .singleton import Singleton
|
||||
|
||||
'''
|
||||
This modules refer to
|
||||
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
|
||||
@@ -15,14 +16,16 @@ class Arguments(metaclass=Singleton):
|
||||
|
||||
class Name:
|
||||
VERSION: str = 'version'
|
||||
OUTPUT: str = 'output'
|
||||
VIDEO: str = 'video'
|
||||
OUTPUT: str = 'output_dir'
|
||||
VIDEO_IDS: str = 'video_id'
|
||||
DEBUG: bool = 'debug'
|
||||
ECHO: bool = 'echo'
|
||||
|
||||
def __init__(self,
|
||||
arguments: Optional[Dict[str, Union[str, bool, int]]] = None):
|
||||
def __init__(self,
|
||||
arguments: Optional[Dict[str, Union[str, bool, int]]] = None):
|
||||
"""
|
||||
Initialize arguments
|
||||
:param arguments: Arguments from cli
|
||||
:param arguments: Arguments from cli
|
||||
(Optional to call singleton instance without parameters)
|
||||
"""
|
||||
|
||||
@@ -33,7 +36,10 @@ class Arguments(metaclass=Singleton):
|
||||
self.print_version: bool = arguments[Arguments.Name.VERSION]
|
||||
self.output: str = arguments[Arguments.Name.OUTPUT]
|
||||
self.video_ids: List[int] = []
|
||||
self.debug: bool = arguments[Arguments.Name.DEBUG]
|
||||
self.echo: bool = arguments[Arguments.Name.ECHO]
|
||||
|
||||
# Videos
|
||||
if arguments[Arguments.Name.VIDEO]:
|
||||
self.video_ids = [video_id
|
||||
for video_id in arguments[Arguments.Name.VIDEO].split(',')]
|
||||
if arguments[Arguments.Name.VIDEO_IDS]:
|
||||
self.video_ids = [video_id
|
||||
for video_id in arguments[Arguments.Name.VIDEO_IDS].split(',')]
|
||||
|
||||
121
pytchat/cli/cli_extractor.py
Normal file
121
pytchat/cli/cli_extractor.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import traceback
|
||||
from httpcore import ReadTimeout as HCReadTimeout, NetworkError as HCNetworkError
|
||||
from json.decoder import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from .arguments import Arguments
|
||||
from .progressbar import ProgressBar
|
||||
from .. import util
|
||||
from .. exceptions import InvalidVideoIdException, NoContents, PatternUnmatchError, UnknownConnectionError
|
||||
from .. processors.html_archiver import HTMLArchiver
|
||||
from .. tool.extract.extractor import Extractor
|
||||
from .. tool.videoinfo import VideoInfo
|
||||
from .. util.extract_video_id import extract_video_id
|
||||
|
||||
|
||||
class CLIExtractor:
|
||||
|
||||
def run(self) -> None:
|
||||
ex = None
|
||||
pbar = None
|
||||
for counter, video_id in enumerate(Arguments().video_ids):
|
||||
if len(Arguments().video_ids) > 1:
|
||||
print(f"\n{'-' * 10} video:{counter + 1} of {len(Arguments().video_ids)} {'-' * 10}")
|
||||
|
||||
try:
|
||||
video_id = extract_video_id(video_id)
|
||||
separated_path = str(Path(Arguments().output)) + os.path.sep
|
||||
path = util.checkpath(separated_path + video_id + '.html')
|
||||
try:
|
||||
info = VideoInfo(video_id)
|
||||
except (PatternUnmatchError, JSONDecodeError) as e:
|
||||
print("Cannot parse video information.:{} {}".format(video_id, type(e)))
|
||||
if Arguments().debug:
|
||||
util.save(str(e.doc), "ERR", ".dat")
|
||||
continue
|
||||
except Exception as e:
|
||||
print("Cannot parse video information.:{} {}".format(video_id, type(e)))
|
||||
continue
|
||||
|
||||
print(f"\n"
|
||||
f" video_id: {video_id}\n"
|
||||
f" channel: {info.get_channel_name()}\n"
|
||||
f" title: {info.get_title()}\n"
|
||||
f" output path: {path}")
|
||||
|
||||
duration = info.get_duration()
|
||||
pbar = ProgressBar(total=(duration * 1000), status_txt="Extracting")
|
||||
ex = Extractor(video_id,
|
||||
callback=pbar.disp,
|
||||
div=10)
|
||||
signal.signal(signal.SIGINT, (lambda a, b: self.cancel(ex, pbar)))
|
||||
|
||||
data = ex.extract()
|
||||
if data == [] or data is None:
|
||||
continue
|
||||
pbar.reset("#", "=", total=1000, status_txt="Rendering ")
|
||||
processor = HTMLArchiver(path, callback=pbar.disp)
|
||||
processor.process(
|
||||
[{'video_id': None,
|
||||
'timeout': 1,
|
||||
'chatdata': (action["replayChatItemAction"]["actions"][0] for action in data)}]
|
||||
)
|
||||
processor.finalize()
|
||||
pbar.reset('#', '#', status_txt='Completed ')
|
||||
pbar.close()
|
||||
print()
|
||||
if pbar.is_cancelled():
|
||||
print("\nThe extraction process has been discontinued.\n")
|
||||
except InvalidVideoIdException:
|
||||
print("Invalid Video ID or URL:", video_id)
|
||||
except NoContents as e:
|
||||
print(f"Abort:{str(e)}:[{video_id}]")
|
||||
except (JSONDecodeError, PatternUnmatchError) as e:
|
||||
print("{}:{}".format(e.msg, video_id))
|
||||
if Arguments().debug:
|
||||
filename = util.save(e.doc, "ERR_", ".dat")
|
||||
traceback.print_exc()
|
||||
print(f"Saved error data: {filename}")
|
||||
except (UnknownConnectionError, HCNetworkError, HCReadTimeout) as e:
|
||||
if Arguments().debug:
|
||||
traceback.print_exc()
|
||||
print(f"An unknown network error occurred during the processing of [{video_id}]. : " + str(e))
|
||||
except Exception as e:
|
||||
print(f"Abort:{str(type(e))} {str(e)[:80]}")
|
||||
if Arguments().debug:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
clear_tasks()
|
||||
|
||||
return
|
||||
|
||||
def cancel(self, ex=None, pbar=None) -> None:
|
||||
'''Called when keyboard interrupted has occurred.
|
||||
'''
|
||||
print("\nKeyboard interrupted.\n")
|
||||
if ex and pbar:
|
||||
ex.cancel()
|
||||
pbar.cancel()
|
||||
|
||||
|
||||
def clear_tasks():
|
||||
'''
|
||||
Clear remained tasks.
|
||||
Called when internal exception has occurred or
|
||||
after each extraction process is completed.
|
||||
'''
|
||||
async def _shutdown():
|
||||
tasks = [t for t in asyncio.all_tasks()
|
||||
if t is not asyncio.current_task()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(_shutdown())
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
if Arguments().debug:
|
||||
traceback.print_exc()
|
||||
22
pytchat/cli/echo.py
Normal file
22
pytchat/cli/echo.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import pytchat
|
||||
from ..exceptions import ChatDataFinished, NoContents
|
||||
from ..util.extract_video_id import extract_video_id
|
||||
|
||||
|
||||
class Echo:
|
||||
def __init__(self, video_id):
|
||||
self.video_id = extract_video_id(video_id)
|
||||
|
||||
def run(self):
|
||||
livechat = pytchat.create(self.video_id)
|
||||
while livechat.is_alive():
|
||||
chatdata = livechat.get()
|
||||
for c in chatdata.sync_items():
|
||||
print(f"{c.datetime} [{c.author.name}] {c.message} {c.amountString}")
|
||||
|
||||
try:
|
||||
livechat.raise_for_status()
|
||||
except (ChatDataFinished, NoContents):
|
||||
print("Chat finished.")
|
||||
except Exception as e:
|
||||
print(type(e), str(e))
|
||||
54
pytchat/cli/progressbar.py
Normal file
54
pytchat/cli/progressbar.py
Normal file
@@ -0,0 +1,54 @@
|
||||
'''
|
||||
This code is based on
|
||||
vladignatyev/progress.py
|
||||
https://gist.github.com/vladignatyev/06860ec2040cb497f0f3
|
||||
(MIT License)
|
||||
'''
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
class ProgressBar:
|
||||
def __init__(self, total, status_txt):
|
||||
self._bar_len = 60
|
||||
self._cancelled = False
|
||||
self.reset(total=total, status_txt=status_txt)
|
||||
|
||||
def reset(self, symbol_done="=", symbol_space=" ", total=100, status_txt=''):
|
||||
self._console_width = shutil.get_terminal_size(fallback=(80, 24)).columns
|
||||
self._symbol_done = symbol_done
|
||||
self._symbol_space = symbol_space
|
||||
self._total = total
|
||||
self._status_txt = status_txt
|
||||
self._count = 0
|
||||
|
||||
def disp(self, _, fetched):
|
||||
self._progress(fetched, self._total)
|
||||
|
||||
def _progress(self, fillin, total):
|
||||
if total == 0 or self._cancelled:
|
||||
return
|
||||
self._count += fillin
|
||||
filled_len = int(round(self._bar_len * self._count / float(total)))
|
||||
percents = round(100.0 * self._count / float(total), 1)
|
||||
if percents > 100:
|
||||
percents = 100.0
|
||||
if filled_len > self._bar_len:
|
||||
filled_len = self._bar_len
|
||||
|
||||
bar = self._symbol_done * filled_len + \
|
||||
self._symbol_space * (self._bar_len - filled_len)
|
||||
disp = f" [{bar}] {percents:>5.1f}% ...{self._status_txt} "[:self._console_width - 1] + '\r'
|
||||
|
||||
sys.stdout.write(disp)
|
||||
sys.stdout.flush()
|
||||
|
||||
def close(self):
|
||||
if not self._cancelled:
|
||||
self._progress(self._total, self._total)
|
||||
|
||||
def cancel(self):
|
||||
self._cancelled = True
|
||||
|
||||
def is_cancelled(self):
|
||||
return self._cancelled
|
||||
@@ -1,9 +1,11 @@
|
||||
'''
|
||||
This modules refer to
|
||||
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
|
||||
@@ -16,4 +18,4 @@ class Singleton(type):
|
||||
return cls._instances[cls]
|
||||
|
||||
def get_instance(cls, *args, **kwargs):
|
||||
cls.__call__(*args, **kwargs)
|
||||
cls.__call__(*args, **kwargs)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import logging
|
||||
import logging # noqa
|
||||
from . import mylogger
|
||||
|
||||
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/84.0.4147.135 Safari/537.36',
|
||||
}
|
||||
|
||||
def logger(module_name: str, loglevel = None):
|
||||
module_logger = mylogger.get_logger(module_name, loglevel = loglevel)
|
||||
|
||||
def logger(module_name: str, loglevel=None):
|
||||
module_logger = mylogger.get_logger(module_name, loglevel=loglevel)
|
||||
return module_logger
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
from logging import NullHandler, getLogger, StreamHandler, FileHandler, Formatter
|
||||
from logging import NullHandler, getLogger, StreamHandler, FileHandler
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_logger(modname,loglevel=logging.DEBUG):
|
||||
def get_logger(modname, loglevel=logging.DEBUG):
|
||||
logger = getLogger(modname)
|
||||
if loglevel == None:
|
||||
if loglevel is None:
|
||||
logger.addHandler(NullHandler())
|
||||
return logger
|
||||
logger.setLevel(loglevel)
|
||||
#create handler1 for showing info
|
||||
# create handler1 for showing info
|
||||
handler1 = StreamHandler()
|
||||
my_formatter = MyFormatter()
|
||||
my_formatter = MyFormatter()
|
||||
handler1.setFormatter(my_formatter)
|
||||
|
||||
handler1.setLevel(loglevel)
|
||||
handler1.setLevel(loglevel)
|
||||
logger.addHandler(handler1)
|
||||
#create handler2 for recording log file
|
||||
# create handler2 for recording log file
|
||||
if loglevel <= logging.DEBUG:
|
||||
handler2 = 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):
|
||||
timestamp = (
|
||||
@@ -35,4 +35,4 @@ class MyFormatter(logging.Formatter):
|
||||
lineno = str(record.lineno).rjust(4)
|
||||
message = record.getMessage()
|
||||
|
||||
return timestamp+'| '+module+' { '+funcname+':'+lineno+'} - '+message
|
||||
return timestamp + '| ' + module + ' { ' + funcname + ':' + lineno + '} - ' + message
|
||||
|
||||
7
pytchat/core/__init__.py
Normal file
7
pytchat/core/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .pytchat import PytchatCore
|
||||
from .. util.extract_video_id import extract_video_id
|
||||
|
||||
|
||||
def create(video_id: str, **kwargs):
|
||||
_vid = extract_video_id(video_id)
|
||||
return PytchatCore(_vid, **kwargs)
|
||||
204
pytchat/core/pytchat.py
Normal file
204
pytchat/core/pytchat.py
Normal file
@@ -0,0 +1,204 @@
|
||||
import httpx
|
||||
import json
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from ..parser.live import Parser
|
||||
from .. import config
|
||||
from .. import exceptions
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
from ..processors.combinator import Combinator
|
||||
from ..util.extract_video_id import extract_video_id
|
||||
|
||||
headers = config.headers
|
||||
MAX_RETRY = 10
|
||||
|
||||
|
||||
class PytchatCore:
|
||||
'''
|
||||
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
|
||||
seektime : int
|
||||
start position of fetching chat (seconds).
|
||||
This option is valid for archived chat only.
|
||||
If negative value, chat data posted before the start of the broadcast
|
||||
will be retrieved as well.
|
||||
|
||||
processor : ChatProcessor
|
||||
|
||||
interruptable : bool
|
||||
Allows keyboard interrupts.
|
||||
Set this parameter to False if your own threading program causes
|
||||
the problem.
|
||||
|
||||
force_replay : bool
|
||||
force to fetch archived chat data, even if specified video is live.
|
||||
|
||||
topchat_only : bool
|
||||
If True, get only top chat.
|
||||
|
||||
hold_exception : bool [default:True]
|
||||
If True, when exceptions occur, the exception is held internally,
|
||||
and can be raised by raise_for_status().
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_is_alive : bool
|
||||
Flag to stop getting chat.
|
||||
'''
|
||||
|
||||
_setup_finished = False
|
||||
|
||||
def __init__(self, video_id,
|
||||
seektime=-1,
|
||||
processor=DefaultProcessor(),
|
||||
interruptable=True,
|
||||
force_replay=False,
|
||||
topchat_only=False,
|
||||
hold_exception=True,
|
||||
logger=config.logger(__name__),
|
||||
):
|
||||
self._video_id = extract_video_id(video_id)
|
||||
self.seektime = seektime
|
||||
if isinstance(processor, tuple):
|
||||
self.processor = Combinator(processor)
|
||||
else:
|
||||
self.processor = processor
|
||||
self._is_alive = True
|
||||
self._is_replay = force_replay
|
||||
self._hold_exception = hold_exception
|
||||
self._exception_holder = None
|
||||
self._parser = Parser(
|
||||
is_replay=self._is_replay,
|
||||
exception_holder=self._exception_holder
|
||||
)
|
||||
self._first_fetch = True
|
||||
self._fetch_url = "live_chat/get_live_chat?continuation="
|
||||
self._topchat_only = topchat_only
|
||||
self._logger = logger
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, lambda a, b: self.terminate())
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
time.sleep(0.1) # sleep shortly to prohibit skipping fetching data
|
||||
"""Fetch first continuation parameter,
|
||||
create and start _listen loop.
|
||||
"""
|
||||
self.continuation = liveparam.getparam(self._video_id, 3)
|
||||
|
||||
def _get_chat_component(self):
|
||||
|
||||
''' Fetch chat data and store them into buffer,
|
||||
get next continuaiton parameter and loop.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
continuation : str
|
||||
parameter for next chat data
|
||||
'''
|
||||
try:
|
||||
with httpx.Client(http2=True) as client:
|
||||
if self.continuation and self._is_alive:
|
||||
contents = self._get_contents(self.continuation, client, headers)
|
||||
metadata, chatdata = self._parser.parse(contents)
|
||||
timeout = metadata['timeoutMs'] / 1000
|
||||
chat_component = {
|
||||
"video_id": self._video_id,
|
||||
"timeout": timeout,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
self.continuation = metadata.get('continuation')
|
||||
return chat_component
|
||||
except exceptions.ChatParseException as e:
|
||||
self._logger.debug(f"[{self._video_id}]{str(e)}")
|
||||
self._raise_exception(e)
|
||||
except Exception as e:
|
||||
self._logger.error(f"{traceback.format_exc(limit=-1)}")
|
||||
self._raise_exception(e)
|
||||
|
||||
def _get_contents(self, continuation, client, 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, client, 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, client, headers))
|
||||
reload_continuation = self._parser.reload_continuation(
|
||||
self._parser.get_contents(livechat_json))
|
||||
if reload_continuation:
|
||||
livechat_json = (self._get_livechat_json(
|
||||
reload_continuation, client, headers))
|
||||
contents = self._parser.get_contents(livechat_json)
|
||||
self._is_replay = True
|
||||
self._first_fetch = False
|
||||
return contents
|
||||
|
||||
def _get_livechat_json(self, continuation, client, headers):
|
||||
'''
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
err = None
|
||||
url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
with client:
|
||||
try:
|
||||
livechat_json = client.get(url, headers=headers).json()
|
||||
break
|
||||
except (json.JSONDecodeError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError) as e:
|
||||
err = e
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
self._logger.error(f"[{self._video_id}]"
|
||||
f"Exceeded retry count. Last error: {str(err)}")
|
||||
self._raise_exception(exceptions.RetryExceedMaxCount())
|
||||
return livechat_json
|
||||
|
||||
def get(self):
|
||||
if self.is_alive():
|
||||
chat_component = self._get_chat_component()
|
||||
return self.processor.process([chat_component])
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_replay(self):
|
||||
return self._is_replay
|
||||
|
||||
def is_alive(self):
|
||||
return self._is_alive
|
||||
|
||||
def terminate(self):
|
||||
self._is_alive = False
|
||||
self.processor.finalize()
|
||||
|
||||
def raise_for_status(self):
|
||||
if self._exception_holder is not None:
|
||||
raise self._exception_holder
|
||||
|
||||
def _raise_exception(self, exception: Exception = None):
|
||||
self.terminate()
|
||||
if self._hold_exception is False:
|
||||
raise exception
|
||||
self._exception_holder = exception
|
||||
@@ -1,28 +1,31 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class Buffer(asyncio.Queue):
|
||||
'''
|
||||
チャットデータを格納するバッファの役割を持つFIFOキュー
|
||||
Buffer for storing chat data.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
maxsize : int
|
||||
格納するチャットブロックの最大個数。0の場合は無限。
|
||||
最大値を超える場合は古いチャットブロックから破棄される。
|
||||
Maximum number of chat blocks to be stored.
|
||||
If it exceeds the maximum, the oldest chat block will be discarded.
|
||||
'''
|
||||
def __init__(self,maxsize = 0):
|
||||
|
||||
def __init__(self, maxsize=0):
|
||||
super().__init__(maxsize)
|
||||
|
||||
async def put(self,item):
|
||||
|
||||
async def put(self, item):
|
||||
if item is None:
|
||||
return
|
||||
return
|
||||
if super().full():
|
||||
super().get_nowait()
|
||||
await super().put(item)
|
||||
|
||||
def put_nowait(self,item):
|
||||
def put_nowait(self, item):
|
||||
if item is None:
|
||||
return
|
||||
return
|
||||
if super().full():
|
||||
super().get_nowait()
|
||||
super().put_nowait(item)
|
||||
@@ -32,4 +35,4 @@ class Buffer(asyncio.Queue):
|
||||
ret.append(await super().get())
|
||||
while not super().empty():
|
||||
ret.append(super().get_nowait())
|
||||
return ret
|
||||
return ret
|
||||
|
||||
@@ -1,93 +1,90 @@
|
||||
import aiohttp, asyncio
|
||||
import datetime
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
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 asyncio import Queue
|
||||
from concurrent.futures import CancelledError
|
||||
from .buffer import Buffer
|
||||
from ..parser.live import Parser
|
||||
from .. import config
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from .. import exceptions
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
from ..processors.combinator import Combinator
|
||||
from ..util.extract_video_id import extract_video_id
|
||||
|
||||
headers = config.headers
|
||||
MAX_RETRY = 10
|
||||
|
||||
|
||||
class LiveChatAsync:
|
||||
'''asyncio(aiohttp)を利用してYouTubeのライブ配信のチャットデータを取得する。
|
||||
'''LiveChatAsync object fetches chat data and stores them
|
||||
in a buffer with asyncio.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
動画ID
|
||||
|
||||
seektime : int
|
||||
(ライブチャット取得時は無視)
|
||||
取得開始するアーカイブ済みチャットの経過時間(秒)
|
||||
マイナス値を指定した場合は、配信開始前のチャットも取得する。
|
||||
start position of fetching chat (seconds).
|
||||
This option is valid for archived chat only.
|
||||
If negative value, chat data posted before the start of the broadcast
|
||||
will be retrieved as well.
|
||||
|
||||
processor : ChatProcessor
|
||||
チャットデータを加工するオブジェクト
|
||||
|
||||
buffer : Buffer(maxsize:20[default])
|
||||
チャットデータchat_componentを格納するバッファ。
|
||||
maxsize : 格納できるchat_componentの個数
|
||||
default値20個。1個で約5~10秒分。
|
||||
buffer : Buffer
|
||||
buffer of chat data fetched background.
|
||||
|
||||
interruptable : bool
|
||||
Ctrl+Cによる処理中断を行うかどうか。
|
||||
Allows keyboard interrupts.
|
||||
Set this parameter to False if your own threading program causes
|
||||
the problem.
|
||||
|
||||
callback : func
|
||||
_listen()関数から一定間隔で自動的に呼びだす関数。
|
||||
function called periodically from _listen().
|
||||
|
||||
done_callback : func
|
||||
listener終了時に呼び出すコールバック。
|
||||
function called when listener ends.
|
||||
|
||||
exception_handler : func
|
||||
例外を処理する関数
|
||||
|
||||
direct_mode : bool
|
||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||
Trueの場合、callbackの設定が必須
|
||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||
|
||||
force_replay : bool
|
||||
Trueの場合、ライブチャットが取得できる場合であっても
|
||||
強制的にアーカイブ済みチャットを取得する。
|
||||
|
||||
If True, invoke specified callback function without using buffer.
|
||||
callback is required. If not, IllegalFunctionCall will be raised.
|
||||
|
||||
force_replay : bool
|
||||
force to fetch archived chat data, even if specified video is live.
|
||||
|
||||
topchat_only : bool
|
||||
Trueの場合、上位チャットのみ取得する。
|
||||
|
||||
If True, get only top chat.
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_is_alive : bool
|
||||
チャット取得を停止するためのフラグ
|
||||
Flag to stop getting chat.
|
||||
'''
|
||||
|
||||
_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,
|
||||
force_replay = False,
|
||||
topchat_only = False,
|
||||
logger = config.logger(__name__),
|
||||
):
|
||||
self.video_id = video_id
|
||||
seektime=-1,
|
||||
processor=DefaultProcessor(),
|
||||
buffer=None,
|
||||
interruptable=True,
|
||||
callback=None,
|
||||
done_callback=None,
|
||||
exception_handler=None,
|
||||
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):
|
||||
self.processor = Combinator(processor)
|
||||
@@ -98,59 +95,56 @@ class LiveChatAsync:
|
||||
self._done_callback = done_callback
|
||||
self._exception_handler = exception_handler
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._is_alive = True
|
||||
self._is_replay = force_replay
|
||||
self._parser = Parser(is_replay = self._is_replay)
|
||||
self._parser = Parser(is_replay=self._is_replay)
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
self._setup()
|
||||
self._first_fetch = True
|
||||
self._fetch_url = "live_chat/get_live_chat?continuation="
|
||||
self._topchat_only = topchat_only
|
||||
self._logger = logger
|
||||
self.exception = None
|
||||
LiveChatAsync._logger = logger
|
||||
|
||||
if not LiveChatAsync._setup_finished:
|
||||
LiveChatAsync._setup_finished = True
|
||||
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))
|
||||
))
|
||||
|
||||
if exception_handler:
|
||||
self._set_exception_handler(exception_handler)
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT,
|
||||
(lambda a, b: self._keyboard_interrupt()))
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
# An exception is raised when direct mode is true and no callback is set.
|
||||
if self._direct_mode:
|
||||
if self._callback is None:
|
||||
raise IllegalFunctionCall(
|
||||
raise exceptions.IllegalFunctionCall(
|
||||
"When direct_mode=True, callback parameter is required.")
|
||||
else:
|
||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||
# Create a default buffer if `direct_mode` is False and buffer is not set.
|
||||
if self._buffer is None:
|
||||
self._buffer = Buffer(maxsize = 20)
|
||||
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成
|
||||
self._buffer = Buffer(maxsize=20)
|
||||
# Create a loop task to call callback if the `callback` param is specified.
|
||||
if self._callback is None:
|
||||
pass
|
||||
pass
|
||||
else:
|
||||
#callbackを呼ぶループタスクの開始
|
||||
# Create a loop task to call callback if the `callback` param is specified.
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(self._callback_loop(self._callback))
|
||||
#_listenループタスクの開始
|
||||
# Start a loop task for _listen()
|
||||
loop = asyncio.get_event_loop()
|
||||
listen_task = loop.create_task(self._startlisten())
|
||||
#add_done_callbackの登録
|
||||
self.listen_task = loop.create_task(self._startlisten())
|
||||
# Register add_done_callback
|
||||
if self._done_callback is None:
|
||||
listen_task.add_done_callback(self.finish)
|
||||
self.listen_task.add_done_callback(self._finish)
|
||||
else:
|
||||
listen_task.add_done_callback(self._done_callback)
|
||||
self.listen_task.add_done_callback(self._done_callback)
|
||||
|
||||
async def _startlisten(self):
|
||||
"""Fetch first continuation parameter,
|
||||
create and start _listen loop.
|
||||
"""
|
||||
initial_continuation = liveparam.getparam(self.video_id,3)
|
||||
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||
await self._listen(initial_continuation)
|
||||
|
||||
async def _listen(self, continuation):
|
||||
@@ -163,39 +157,41 @@ class LiveChatAsync:
|
||||
parameter for next chat data
|
||||
'''
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
while(continuation and self._is_alive):
|
||||
continuation = await self._check_pause(continuation)
|
||||
contents = await self._get_contents(
|
||||
continuation, session, headers)
|
||||
metadata, chatdata = self._parser.parse(contents)
|
||||
continuation, client, headers)
|
||||
metadata, chatdata = self._parser.parse(contents)
|
||||
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
timeout = metadata['timeoutMs'] / 1000
|
||||
chat_component = {
|
||||
"video_id" : self.video_id,
|
||||
"timeout" : timeout,
|
||||
"chatdata" : chatdata
|
||||
"video_id": self._video_id,
|
||||
"timeout": timeout,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
time_mark =time.time()
|
||||
time_mark = time.time()
|
||||
if self._direct_mode:
|
||||
processed_chat = self.processor.process([chat_component])
|
||||
if isinstance(processed_chat,tuple):
|
||||
processed_chat = self.processor.process(
|
||||
[chat_component])
|
||||
if isinstance(processed_chat, tuple):
|
||||
await self._callback(*processed_chat)
|
||||
else:
|
||||
await self._callback(processed_chat)
|
||||
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:
|
||||
self._logger.debug(f"[{self.video_id}]{str(e)}")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
diff_time = timeout - (time.time() - time_mark)
|
||||
await asyncio.sleep(diff_time)
|
||||
continuation = metadata.get('continuation')
|
||||
except exceptions.ChatParseException as e:
|
||||
self._logger.debug(f"[{self._video_id}]{str(e)}")
|
||||
raise
|
||||
except Exception:
|
||||
self._logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
self._logger.debug(f"[{self.video_id}]finished fetching chat.")
|
||||
raise
|
||||
|
||||
self._logger.debug(f"[{self._video_id}] finished fetching chat.")
|
||||
|
||||
|
||||
async def _check_pause(self, continuation):
|
||||
if self._pauser.empty():
|
||||
@@ -207,21 +203,19 @@ class LiveChatAsync:
|
||||
self._pauser.put_nowait(None)
|
||||
if not self._is_replay:
|
||||
continuation = liveparam.getparam(
|
||||
self.video_id, 3, self._topchat_only)
|
||||
self._video_id, 3, self._topchat_only)
|
||||
return continuation
|
||||
|
||||
async def _get_contents(self, continuation, session, headers):
|
||||
async def _get_contents(self, continuation, client, headers):
|
||||
'''Get 'continuationContents' from livechat json.
|
||||
If contents is None at first fetching,
|
||||
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)
|
||||
)
|
||||
livechat_json = await self._get_livechat_json(continuation, client, headers)
|
||||
contents = self._parser.get_contents(livechat_json)
|
||||
if self._first_fetch:
|
||||
if contents is None or self._is_replay:
|
||||
@@ -229,50 +223,49 @@ class LiveChatAsync:
|
||||
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))
|
||||
self._video_id, self.seektime, self._topchat_only)
|
||||
livechat_json = (await self._get_livechat_json(
|
||||
continuation, client, 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))
|
||||
livechat_json = (await self._get_livechat_json(
|
||||
reload_continuation, client, 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, client, headers):
|
||||
'''
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||
url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
async with session.get(url ,headers = headers) as resp:
|
||||
try:
|
||||
text = await resp.text()
|
||||
livechat_json = json.loads(text)
|
||||
break
|
||||
except (ClientConnectorError,json.JSONDecodeError) :
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
try:
|
||||
resp = await client.get(url, headers=headers)
|
||||
livechat_json = resp.json()
|
||||
break
|
||||
except (json.JSONDecodeError, httpx.HTTPError):
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
else:
|
||||
self._logger.error(f"[{self.video_id}]"
|
||||
f"Exceeded retry count. status_code={status_code}")
|
||||
self._logger.error(f"[{self._video_id}]"
|
||||
f"Exceeded retry count.")
|
||||
return None
|
||||
return livechat_json
|
||||
|
||||
async def _callback_loop(self,callback):
|
||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||
|
||||
async def _callback_loop(self, callback):
|
||||
""" If a callback is specified in the constructor,
|
||||
it throws chat data at regular intervals to the
|
||||
function specified in the callback in the backgroun
|
||||
|
||||
Parameter
|
||||
---------
|
||||
callback : func
|
||||
加工済みのチャットデータを渡す先の関数。
|
||||
function to which the processed chat data is passed.
|
||||
"""
|
||||
while self.is_alive():
|
||||
items = await self._buffer.get()
|
||||
@@ -283,17 +276,22 @@ class LiveChatAsync:
|
||||
await self._callback(processed_chat)
|
||||
|
||||
async def get(self):
|
||||
""" bufferからデータを取り出し、processorに投げ、
|
||||
加工済みのチャットデータを返す。
|
||||
|
||||
"""
|
||||
Retrieves data from the buffer,
|
||||
throws it to the processor,
|
||||
and returns the processed chat data.
|
||||
|
||||
Returns
|
||||
: Processorによって加工されたチャットデータ
|
||||
: Chat data processed by the Processor
|
||||
"""
|
||||
if self._callback is None:
|
||||
items = await self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
raise IllegalFunctionCall(
|
||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||
if self.is_alive():
|
||||
items = await self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
else:
|
||||
return []
|
||||
raise exceptions.IllegalFunctionCall(
|
||||
"Callback parameter is already set, so get() cannot be performed.")
|
||||
|
||||
def is_replay(self):
|
||||
return self._is_replay
|
||||
@@ -309,40 +307,47 @@ class LiveChatAsync:
|
||||
return
|
||||
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()
|
||||
def _finish(self, sender):
|
||||
'''Called when the _listen() task finished.'''
|
||||
try:
|
||||
self._task_finished()
|
||||
except CancelledError:
|
||||
self._logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
||||
self._logger.debug(f'[{self._video_id}] cancelled:{sender}')
|
||||
|
||||
def terminate(self):
|
||||
'''
|
||||
Listenerを終了する。
|
||||
'''
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
self._is_alive = False
|
||||
if self._direct_mode == False:
|
||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
||||
self._buffer.put_nowait({'chatdata':'','timeout':0})
|
||||
self._logger.info(f'[{self.video_id}]finished.')
|
||||
|
||||
self._buffer.put_nowait({})
|
||||
self.processor.finalize()
|
||||
|
||||
def _keyboard_interrupt(self):
|
||||
self.exception = exceptions.ChatDataFinished()
|
||||
self.terminate()
|
||||
|
||||
def _task_finished(self):
|
||||
'''
|
||||
Terminate fetching chats.
|
||||
'''
|
||||
if self.is_alive():
|
||||
self.terminate()
|
||||
try:
|
||||
self.listen_task.result()
|
||||
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}] finished.')
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.exception is not None:
|
||||
raise self.exception
|
||||
|
||||
@classmethod
|
||||
def _set_exception_handler(cls, handler):
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.set_exception_handler(handler)
|
||||
|
||||
@classmethod
|
||||
async def shutdown(cls, event, sig = None, handler=None):
|
||||
cls._logger.debug("shutdown...")
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not
|
||||
asyncio.current_task()]
|
||||
[task.cancel() for task in tasks]
|
||||
|
||||
cls._logger.debug(f"complete remaining tasks...")
|
||||
await asyncio.gather(*tasks,return_exceptions=True)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.stop()
|
||||
@@ -1,38 +1,40 @@
|
||||
|
||||
import queue
|
||||
|
||||
|
||||
class Buffer(queue.Queue):
|
||||
'''
|
||||
チャットデータを格納するバッファの役割を持つFIFOキュー
|
||||
Buffer for storing chat data.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
max_size : int
|
||||
格納するチャットブロックの最大個数。0の場合は無限。
|
||||
最大値を超える場合は古いチャットブロックから破棄される。
|
||||
maxsize : int
|
||||
Maximum number of chat blocks to be stored.
|
||||
If it exceeds the maximum, the oldest chat block will be discarded.
|
||||
'''
|
||||
def __init__(self,maxsize = 0):
|
||||
|
||||
def __init__(self, maxsize=0):
|
||||
super().__init__(maxsize=maxsize)
|
||||
|
||||
def put(self,item):
|
||||
|
||||
def put(self, item):
|
||||
if item is None:
|
||||
return
|
||||
return
|
||||
if super().full():
|
||||
super().get_nowait()
|
||||
else:
|
||||
super().put(item)
|
||||
|
||||
def put_nowait(self,item):
|
||||
|
||||
def put_nowait(self, item):
|
||||
if item is None:
|
||||
return
|
||||
return
|
||||
if super().full():
|
||||
super().get_nowait()
|
||||
else:
|
||||
super().put_nowait(item)
|
||||
|
||||
|
||||
def get(self):
|
||||
ret = []
|
||||
ret.append(super().get())
|
||||
while not super().empty():
|
||||
ret.append(super().get())
|
||||
return ret
|
||||
return ret
|
||||
|
||||
@@ -1,93 +1,90 @@
|
||||
import requests
|
||||
import datetime
|
||||
import httpx
|
||||
import json
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
from .buffer import Buffer
|
||||
from ..parser.live import Parser
|
||||
from .. import config
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from .. import exceptions
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
from ..processors.combinator import Combinator
|
||||
from ..util.extract_video_id import extract_video_id
|
||||
|
||||
headers = config.headers
|
||||
MAX_RETRY = 10
|
||||
|
||||
|
||||
class LiveChat:
|
||||
''' スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する
|
||||
'''
|
||||
LiveChat object fetches chat data and stores them
|
||||
in a buffer with ThreadpoolExecutor.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
動画ID
|
||||
|
||||
|
||||
seektime : int
|
||||
(ライブチャット取得時は無視)
|
||||
取得開始するアーカイブ済みチャットの経過時間(秒)
|
||||
マイナス値を指定した場合は、配信開始前のチャットも取得する。
|
||||
start position of fetching chat (seconds).
|
||||
This option is valid for archived chat only.
|
||||
If negative value, chat data posted before the start of the broadcast
|
||||
will be retrieved as well.
|
||||
|
||||
processor : ChatProcessor
|
||||
チャットデータを加工するオブジェクト
|
||||
|
||||
buffer : Buffer(maxsize:20[default])
|
||||
チャットデータchat_componentを格納するバッファ。
|
||||
maxsize : 格納できるchat_componentの個数
|
||||
default値20個。1個で約5~10秒分。
|
||||
buffer : Buffer
|
||||
buffer of chat data fetched background.
|
||||
|
||||
interruptable : bool
|
||||
Ctrl+Cによる処理中断を行うかどうか。
|
||||
Allows keyboard interrupts.
|
||||
Set this parameter to False if your own threading program causes
|
||||
the problem.
|
||||
|
||||
callback : func
|
||||
_listen()関数から一定間隔で自動的に呼びだす関数。
|
||||
function called periodically from _listen().
|
||||
|
||||
done_callback : func
|
||||
listener終了時に呼び出すコールバック。
|
||||
function called when listener ends.
|
||||
|
||||
direct_mode : bool
|
||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||
Trueの場合、callbackの設定が必須
|
||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||
If True, invoke specified callback function without using buffer.
|
||||
callback is required. If not, IllegalFunctionCall will be raised.
|
||||
|
||||
force_replay : bool
|
||||
Trueの場合、ライブチャットが取得できる場合であっても
|
||||
強制的にアーカイブ済みチャットを取得する。
|
||||
force_replay : bool
|
||||
force to fetch archived chat data, even if specified video is live.
|
||||
|
||||
topchat_only : bool
|
||||
Trueの場合、上位チャットのみ取得する。
|
||||
|
||||
If True, get only top chat.
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_executor : ThreadPoolExecutor
|
||||
チャットデータ取得ループ(_listen)用のスレッド
|
||||
This is used for _listen() loop.
|
||||
|
||||
_is_alive : bool
|
||||
チャット取得を停止するためのフラグ
|
||||
Flag to stop getting chat.
|
||||
'''
|
||||
|
||||
_setup_finished = False
|
||||
#チャット監視中のListenerのリスト
|
||||
_listeners = []
|
||||
|
||||
def __init__(self, video_id,
|
||||
seektime = 0,
|
||||
processor = DefaultProcessor(),
|
||||
buffer = None,
|
||||
interruptable = True,
|
||||
callback = None,
|
||||
done_callback = None,
|
||||
direct_mode = False,
|
||||
force_replay = False,
|
||||
topchat_only = False,
|
||||
logger = config.logger(__name__)
|
||||
):
|
||||
self.video_id = video_id
|
||||
seektime=-1,
|
||||
processor=DefaultProcessor(),
|
||||
buffer=None,
|
||||
interruptable=True,
|
||||
callback=None,
|
||||
done_callback=None,
|
||||
direct_mode=False,
|
||||
force_replay=False,
|
||||
topchat_only=False,
|
||||
logger=config.logger(__name__)
|
||||
):
|
||||
self._video_id = extract_video_id(video_id)
|
||||
self.seektime = seektime
|
||||
if isinstance(processor, tuple):
|
||||
self.processor = Combinator(processor)
|
||||
@@ -98,57 +95,53 @@ class LiveChat:
|
||||
self._done_callback = done_callback
|
||||
self._executor = ThreadPoolExecutor(max_workers=2)
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._is_alive = True
|
||||
self._is_replay = force_replay
|
||||
self._parser = Parser(is_replay = self._is_replay)
|
||||
self._parser = Parser(is_replay=self._is_replay)
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
self._setup()
|
||||
self._first_fetch = True
|
||||
self._fetch_url = "live_chat/get_live_chat?continuation="
|
||||
self._topchat_only = topchat_only
|
||||
self._event = Event()
|
||||
self._logger = logger
|
||||
LiveChat._logger = logger
|
||||
if not LiveChat._setup_finished:
|
||||
LiveChat._setup_finished = True
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, (lambda a, b:
|
||||
(LiveChat.shutdown(None,signal.SIGINT,b))
|
||||
))
|
||||
LiveChat._listeners.append(self)
|
||||
self.exception = None
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, lambda a, b: self.terminate())
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
# An exception is raised when direct mode is true and no callback is set.
|
||||
if self._direct_mode:
|
||||
if self._callback is None:
|
||||
raise IllegalFunctionCall(
|
||||
raise exceptions.IllegalFunctionCall(
|
||||
"When direct_mode=True, callback parameter is required.")
|
||||
else:
|
||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||
# Create a default buffer if `direct_mode` is False and buffer is not set.
|
||||
if self._buffer is None:
|
||||
self._buffer = Buffer(maxsize = 20)
|
||||
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成
|
||||
self._buffer = Buffer(maxsize=20)
|
||||
# Create a loop task to call callback if the `callback` param is specified.
|
||||
if self._callback is None:
|
||||
pass
|
||||
pass
|
||||
else:
|
||||
#callbackを呼ぶループタスクの開始
|
||||
self._executor.submit(self._callback_loop,self._callback)
|
||||
#_listenループタスクの開始
|
||||
listen_task = self._executor.submit(self._startlisten)
|
||||
#add_done_callbackの登録
|
||||
# Start a loop task calling callback function.
|
||||
self._executor.submit(self._callback_loop, self._callback)
|
||||
# Start a loop task for _listen()
|
||||
self.listen_task = self._executor.submit(self._startlisten)
|
||||
# Register add_done_callback
|
||||
if self._done_callback is None:
|
||||
listen_task.add_done_callback(self.finish)
|
||||
self.listen_task.add_done_callback(self._finish)
|
||||
else:
|
||||
listen_task.add_done_callback(self._done_callback)
|
||||
self.listen_task.add_done_callback(self._done_callback)
|
||||
|
||||
def _startlisten(self):
|
||||
time.sleep(0.1) #sleep shortly to prohibit skipping fetching data
|
||||
time.sleep(0.1) # sleep shortly to prohibit skipping fetching data
|
||||
"""Fetch first continuation parameter,
|
||||
create and start _listen loop.
|
||||
"""
|
||||
initial_continuation = liveparam.getparam(self.video_id,3)
|
||||
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||
self._listen(initial_continuation)
|
||||
|
||||
|
||||
def _listen(self, continuation):
|
||||
''' Fetch chat data and store them into buffer,
|
||||
get next continuaiton parameter and loop.
|
||||
@@ -159,39 +152,39 @@ class LiveChat:
|
||||
parameter for next chat data
|
||||
'''
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
with httpx.Client(http2=True) as client:
|
||||
while(continuation and self._is_alive):
|
||||
continuation = self._check_pause(continuation)
|
||||
contents = self._get_contents(
|
||||
continuation, session, headers)
|
||||
metadata, chatdata = self._parser.parse(contents)
|
||||
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
contents = self._get_contents(continuation, client, headers)
|
||||
metadata, chatdata = self._parser.parse(contents)
|
||||
timeout = metadata['timeoutMs'] / 1000
|
||||
chat_component = {
|
||||
"video_id" : self.video_id,
|
||||
"timeout" : timeout,
|
||||
"chatdata" : chatdata
|
||||
"video_id": self._video_id,
|
||||
"timeout": timeout,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
time_mark =time.time()
|
||||
time_mark = time.time()
|
||||
if self._direct_mode:
|
||||
processed_chat = self.processor.process([chat_component])
|
||||
if isinstance(processed_chat,tuple):
|
||||
processed_chat = self.processor.process(
|
||||
[chat_component])
|
||||
if isinstance(processed_chat, tuple):
|
||||
self._callback(*processed_chat)
|
||||
else:
|
||||
self._callback(processed_chat)
|
||||
else:
|
||||
self._buffer.put(chat_component)
|
||||
diff_time = timeout - (time.time()-time_mark)
|
||||
time.sleep(diff_time if diff_time > 0 else 0)
|
||||
continuation = metadata.get('continuation')
|
||||
except ChatParseException as e:
|
||||
self._logger.debug(f"[{self.video_id}]{str(e)}")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
self._logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
self._logger.debug(f"[{self.video_id}]finished fetching chat.")
|
||||
diff_time = timeout - (time.time() - time_mark)
|
||||
self._event.wait(diff_time if diff_time > 0 else 0)
|
||||
continuation = metadata.get('continuation')
|
||||
except exceptions.ChatParseException as e:
|
||||
self._logger.debug(f"[{self._video_id}]{str(e)}")
|
||||
raise
|
||||
except Exception:
|
||||
self._logger.error(f"{traceback.format_exc(limit=-1)}")
|
||||
raise
|
||||
|
||||
self._logger.debug(f"[{self._video_id}] finished fetching chat.")
|
||||
|
||||
|
||||
def _check_pause(self, continuation):
|
||||
if self._pauser.empty():
|
||||
@@ -202,20 +195,20 @@ class LiveChat:
|
||||
'''
|
||||
self._pauser.put_nowait(None)
|
||||
if not self._is_replay:
|
||||
continuation = liveparam.getparam(self.video_id,3)
|
||||
continuation = liveparam.getparam(self._video_id, 3)
|
||||
return continuation
|
||||
|
||||
def _get_contents(self, continuation, session, headers):
|
||||
def _get_contents(self, continuation, client, headers):
|
||||
'''Get 'continuationContents' from livechat json.
|
||||
If contents is None at first fetching,
|
||||
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)
|
||||
livechat_json = (
|
||||
self._get_livechat_json(continuation, client, headers)
|
||||
)
|
||||
contents = self._parser.get_contents(livechat_json)
|
||||
if self._first_fetch:
|
||||
@@ -224,50 +217,48 @@ class LiveChat:
|
||||
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))
|
||||
self._video_id, self.seektime, self._topchat_only)
|
||||
livechat_json = (self._get_livechat_json(continuation, client, 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))
|
||||
reload_continuation, client, 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, client, headers):
|
||||
'''
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||
url = f"https://www.youtube.com/{self._fetch_url}{continuation}&pbj=1"
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
with session.get(url ,headers = headers) as resp:
|
||||
with client:
|
||||
try:
|
||||
text = resp.text
|
||||
livechat_json = json.loads(text)
|
||||
livechat_json = client.get(url, headers=headers).json()
|
||||
break
|
||||
except json.JSONDecodeError :
|
||||
time.sleep(1)
|
||||
except (json.JSONDecodeError, httpx.HTTPError):
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
self._logger.error(f"[{self.video_id}]"
|
||||
f"Exceeded retry count. status_code={status_code}")
|
||||
return None
|
||||
self._logger.error(f"[{self._video_id}]"
|
||||
f"Exceeded retry count.")
|
||||
raise exceptions.RetryExceedMaxCount()
|
||||
return livechat_json
|
||||
|
||||
def _callback_loop(self,callback):
|
||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||
|
||||
|
||||
def _callback_loop(self, callback):
|
||||
""" If a callback is specified in the constructor,
|
||||
it throws chat data at regular intervals to the
|
||||
function specified in the callback in the backgroun
|
||||
|
||||
Parameter
|
||||
---------
|
||||
callback : func
|
||||
加工済みのチャットデータを渡す先の関数。
|
||||
function to which the processed chat data is passed.
|
||||
"""
|
||||
while self.is_alive():
|
||||
items = self._buffer.get()
|
||||
@@ -278,17 +269,22 @@ class LiveChat:
|
||||
self._callback(processed_chat)
|
||||
|
||||
def get(self):
|
||||
""" bufferからデータを取り出し、processorに投げ、
|
||||
加工済みのチャットデータを返す。
|
||||
|
||||
"""
|
||||
Retrieves data from the buffer,
|
||||
throws it to the processor,
|
||||
and returns the processed chat data.
|
||||
|
||||
Returns
|
||||
: Processorによって加工されたチャットデータ
|
||||
: Chat data processed by the Processor
|
||||
"""
|
||||
if self._callback is None:
|
||||
items = self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
raise IllegalFunctionCall(
|
||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||
if self.is_alive():
|
||||
items = self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
else:
|
||||
return []
|
||||
raise exceptions.IllegalFunctionCall(
|
||||
"Callback parameter is already set, so get() cannot be performed.")
|
||||
|
||||
def is_replay(self):
|
||||
return self._is_replay
|
||||
@@ -304,29 +300,39 @@ class LiveChat:
|
||||
return
|
||||
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()
|
||||
def _finish(self, sender):
|
||||
'''Called when the _listen() task finished.'''
|
||||
try:
|
||||
self._task_finished()
|
||||
except CancelledError:
|
||||
self._logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
||||
self._logger.debug(f'[{self._video_id}] cancelled:{sender}')
|
||||
|
||||
def terminate(self):
|
||||
'''
|
||||
Listenerを終了する。
|
||||
Terminate fetching chats.
|
||||
'''
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
self._is_alive = False
|
||||
if self._direct_mode == False:
|
||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
||||
self._buffer.put({'chatdata':'','timeout':0})
|
||||
self._logger.info(f'[{self.video_id}]finished.')
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls, event, sig = None, handler=None):
|
||||
cls._logger.debug("shutdown...")
|
||||
for t in LiveChat._listeners:
|
||||
t._is_alive = False
|
||||
self._buffer.put({})
|
||||
self._event.set()
|
||||
self.processor.finalize()
|
||||
|
||||
def _task_finished(self):
|
||||
if self.is_alive():
|
||||
self.terminate()
|
||||
try:
|
||||
self.listen_task.result()
|
||||
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}] finished.')
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.exception is not None:
|
||||
raise self.exception
|
||||
|
||||
@@ -4,11 +4,6 @@ class ChatParseException(Exception):
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoYtinitialdataException(ChatParseException):
|
||||
'''
|
||||
Thrown when the video is not found.
|
||||
'''
|
||||
pass
|
||||
|
||||
class ResponseContextError(ChatParseException):
|
||||
'''
|
||||
@@ -16,37 +11,71 @@ class ResponseContextError(ChatParseException):
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoLivechatRendererException(ChatParseException):
|
||||
'''
|
||||
Thrown when livechatRenderer is missing in JSON.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class NoContentsException(ChatParseException):
|
||||
class NoContents(ChatParseException):
|
||||
'''
|
||||
Thrown when ContinuationContents is missing in JSON.
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoContinuationsException(ChatParseException):
|
||||
|
||||
class NoContinuation(ChatParseException):
|
||||
'''
|
||||
Thrown when continuation is missing in ContinuationContents.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class IllegalFunctionCall(Exception):
|
||||
'''
|
||||
Thrown when get () is called even though
|
||||
set_callback () has been executed.
|
||||
Thrown when get() is called even though
|
||||
set_callback() has been executed.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class InvalidVideoIdException(Exception):
|
||||
'''
|
||||
Thrown when the video_id is not exist (VideoInfo).
|
||||
'''
|
||||
pass
|
||||
def __init__(self, doc):
|
||||
self.msg = "InvalidVideoIdException"
|
||||
self.doc = doc
|
||||
|
||||
|
||||
class UnknownConnectionError(Exception):
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
class VideoInfoParseError(Exception):
|
||||
'''
|
||||
Base exception when parsing video info.
|
||||
'''
|
||||
|
||||
|
||||
class PatternUnmatchError(VideoInfoParseError):
|
||||
'''
|
||||
Thrown when failed to parse video info with unmatched pattern.
|
||||
'''
|
||||
def __init__(self, doc=''):
|
||||
self.msg = "PatternUnmatchError"
|
||||
self.doc = doc
|
||||
|
||||
@@ -1,107 +1,55 @@
|
||||
from base64 import urlsafe_b64encode as b64enc
|
||||
from functools import reduce
|
||||
import math
|
||||
import random
|
||||
import urllib.parse
|
||||
from .pb.header_pb2 import Header
|
||||
from .pb.replay_pb2 import Continuation
|
||||
from urllib.parse import quote
|
||||
import base64
|
||||
|
||||
'''
|
||||
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.
|
||||
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_sep_1 = b'\x1A\x13\xEA\xA8\xDD\xB9\x01\x0D\x0A\x0B'
|
||||
header_terminator = b'\x20\x01'
|
||||
def _gen_vid(video_id) -> str:
|
||||
header = Header()
|
||||
header.info.video.id = video_id
|
||||
header.terminator = 1
|
||||
return base64.urlsafe_b64encode(header.SerializeToString()).decode()
|
||||
|
||||
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 _build(video_id, seektime, topchat_only) -> str:
|
||||
chattype = 1
|
||||
timestamp = 0
|
||||
if topchat_only:
|
||||
chattype = 4
|
||||
|
||||
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'
|
||||
fetch_before_start = 3
|
||||
if seektime < 0:
|
||||
times =_nval(0)
|
||||
switch = b'\x04'
|
||||
elif seektime == 0:
|
||||
times =_nval(1)
|
||||
switch = b'\x03'
|
||||
fetch_before_start = 4
|
||||
elif seektime == 0:
|
||||
timestamp = 1
|
||||
else:
|
||||
times =_nval(int(seektime*1000000))
|
||||
switch = b'\x03'
|
||||
parity = b'\x00'
|
||||
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())
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
def getparam(video_id, seektime = 0, topchat_only = False):
|
||||
def getparam(video_id, seektime=-1, topchat_only=False) -> str:
|
||||
'''
|
||||
Parameter
|
||||
---------
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
from base64 import urlsafe_b64encode as b64enc
|
||||
from functools import reduce
|
||||
import math
|
||||
import random
|
||||
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 functools import reduce
|
||||
import time
|
||||
from .pb.header_pb2 import Header
|
||||
from .pb.live_pb2 import Continuation
|
||||
from urllib.parse import quote
|
||||
import base64
|
||||
import random
|
||||
import urllib.parse
|
||||
import time
|
||||
|
||||
'''
|
||||
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.
|
||||
Parameter
|
||||
---------
|
||||
@@ -19,139 +23,60 @@ def _gen_vid(video_id):
|
||||
|
||||
Return
|
||||
---------
|
||||
byte[] : base64 encoded video_id parameter.
|
||||
str : base64 encoded video_id parameter.
|
||||
"""
|
||||
header_magic = b'\x0A\x0F\x0A\x0D\x0A'
|
||||
header_id = video_id.encode()
|
||||
header_sep_1 = b'\x1A'
|
||||
header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A'
|
||||
header_suburl = ('https://www.youtube.com/live_chat?v='
|
||||
f'{video_id}&is_popout=1').encode()
|
||||
header_terminator = b'\x20\x02'
|
||||
header = Header()
|
||||
header.info.video.id = video_id
|
||||
header.terminator = 1
|
||||
return base64.urlsafe_b64encode(header.SerializeToString()).decode()
|
||||
|
||||
item = [
|
||||
header_magic,
|
||||
_nval(len(header_id)),
|
||||
header_id,
|
||||
header_sep_1,
|
||||
header_sep_2,
|
||||
_nval(len(header_suburl)),
|
||||
header_suburl,
|
||||
header_terminator
|
||||
]
|
||||
|
||||
return urllib.parse.quote(
|
||||
b64enc(reduce(lambda x, y: x+y, item)).decode()
|
||||
).encode()
|
||||
def _build(video_id, ts1, ts2, ts3, ts4, ts5, topchat_only) -> str:
|
||||
chattype = 1
|
||||
if topchat_only:
|
||||
chattype = 4
|
||||
continuation = Continuation()
|
||||
entity = continuation.entity
|
||||
|
||||
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
|
||||
entity.header = _gen_vid(video_id)
|
||||
entity.timestamp1 = ts1
|
||||
entity.s6 = 0
|
||||
entity.s7 = 0
|
||||
entity.s8 = 1
|
||||
entity.body.b1 = 0
|
||||
entity.body.b2 = 0
|
||||
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, topchat_only):
|
||||
#_short_type2
|
||||
switch_01 = b'\x04' if topchat_only else b'\x01'
|
||||
parity = _tzparity(video_id, _ts1^_ts2^_ts3^_ts4^_ts5)
|
||||
|
||||
header_magic= b'\xD2\x87\xCC\xC8\x03'
|
||||
sep_0 = b'\x1A'
|
||||
vid = _gen_vid(video_id)
|
||||
time_tag = b'\x28'
|
||||
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):
|
||||
|
||||
n = int(time.time())
|
||||
|
||||
_ts1= n - random.uniform(0,1*3)
|
||||
_ts2= n - random.uniform(0.01,0.99)
|
||||
_ts3= n - past_sec + random.uniform(0,1)
|
||||
_ts4= n - random.uniform(10*60,60*60)
|
||||
_ts5= n - random.uniform(0.01,0.99)
|
||||
return list(map(lambda x:int(x*1000000),[_ts1,_ts2,_ts3,_ts4,_ts5]))
|
||||
_ts1 = n - random.uniform(0, 1 * 3)
|
||||
_ts2 = n - random.uniform(0.01, 0.99)
|
||||
_ts3 = n - past_sec + random.uniform(0, 1)
|
||||
_ts4 = n - random.uniform(10 * 60, 60 * 60)
|
||||
_ts5 = n - random.uniform(0.01, 0.99)
|
||||
return list(map(lambda x: int(x * 1000000), [_ts1, _ts2, _ts3, _ts4, _ts5]))
|
||||
|
||||
|
||||
def getparam(video_id, past_sec = 0, topchat_only = False):
|
||||
def getparam(video_id, past_sec=0, topchat_only=False) -> str:
|
||||
'''
|
||||
Parameter
|
||||
---------
|
||||
@@ -160,5 +85,4 @@ def getparam(video_id, past_sec = 0, topchat_only = False):
|
||||
topchat_only : bool
|
||||
if True, fetch only 'top chat'
|
||||
'''
|
||||
return _build(video_id,*_times(past_sec),topchat_only)
|
||||
|
||||
return _build(video_id, *_times(past_sec), topchat_only)
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -4,27 +4,34 @@ pytchat.parser.live
|
||||
Parser of live chat JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
from .. exceptions import (
|
||||
ResponseContextError,
|
||||
NoContentsException,
|
||||
NoContinuationsException,
|
||||
ChatParseException )
|
||||
from .. import exceptions
|
||||
|
||||
|
||||
class Parser:
|
||||
|
||||
__slots__ = ['is_replay']
|
||||
'''
|
||||
Parser of chat json.
|
||||
|
||||
def __init__(self, is_replay):
|
||||
Parameter
|
||||
----------
|
||||
is_replay : bool
|
||||
|
||||
exception_holder : Object [default:Npne]
|
||||
The object holding exceptions.
|
||||
This is passed from the parent livechat object.
|
||||
'''
|
||||
__slots__ = ['is_replay', 'exception_holder']
|
||||
|
||||
def __init__(self, is_replay, exception_holder=None):
|
||||
self.is_replay = is_replay
|
||||
self.exception_holder = exception_holder
|
||||
|
||||
def get_contents(self, jsn):
|
||||
if jsn is None:
|
||||
raise ChatParseException('Called with none JSON object.')
|
||||
if jsn is None:
|
||||
self.raise_exception(exceptions.IllegalFunctionCall('Called with none JSON object.'))
|
||||
if jsn['response']['responseContext'].get('errors'):
|
||||
raise ResponseContextError('The video_id would be wrong,'
|
||||
'or video is deleted or private.')
|
||||
contents=jsn['response'].get('continuationContents')
|
||||
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):
|
||||
@@ -40,58 +47,62 @@ class Parser:
|
||||
+ metadata : dict
|
||||
+ timeout
|
||||
+ video_id
|
||||
+ continuation
|
||||
+ continuation
|
||||
+ chatdata : List[dict]
|
||||
"""
|
||||
|
||||
if contents is None:
|
||||
'''Broadcasting end or cannot fetch chat stream'''
|
||||
raise NoContentsException('Chat data stream is empty.')
|
||||
self.raise_exception(exceptions.NoContents('Chat data stream is empty.'))
|
||||
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont is None:
|
||||
raise NoContinuationsException('No Continuation')
|
||||
metadata = (cont.get('invalidationContinuationData') or
|
||||
cont.get('timedContinuationData') or
|
||||
cont.get('reloadContinuationData') or
|
||||
cont.get('liveChatReplayContinuationData')
|
||||
self.raise_exception(exceptions.NoContinuation('No Continuation'))
|
||||
metadata = (cont.get('invalidationContinuationData')
|
||||
or cont.get('timedContinuationData')
|
||||
or cont.get('reloadContinuationData')
|
||||
or cont.get('liveChatReplayContinuationData')
|
||||
)
|
||||
if metadata is None:
|
||||
if cont.get("playerSeekContinuationData"):
|
||||
raise ChatParseException('Finished chat data')
|
||||
self.raise_exception(exceptions.ChatDataFinished('Finished chat data'))
|
||||
unknown = list(cont.keys())[0]
|
||||
if unknown:
|
||||
raise ChatParseException(f"Received unknown continuation type:{unknown}")
|
||||
self.raise_exception(exceptions.ReceivedUnknownContinuation(
|
||||
f"Received unknown continuation type:{unknown}"))
|
||||
else:
|
||||
raise ChatParseException('Cannot extract continuation data')
|
||||
self.raise_exception(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.
|
||||
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'''
|
||||
self.raise_exception(exceptions.NoContents('Chat data stream is empty.'))
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont.get("liveChatReplayContinuationData"):
|
||||
#chat data exist.
|
||||
# chat data exist.
|
||||
return None
|
||||
#chat data do not exist, get playerSeekContinuationData.
|
||||
# chat data do not exist, get playerSeekContinuationData.
|
||||
init_cont = cont.get("playerSeekContinuationData")
|
||||
if init_cont:
|
||||
return init_cont.get("continuation")
|
||||
raise ChatParseException('Finished chat data')
|
||||
self.raise_exception(exceptions.ChatDataFinished('Finished chat data'))
|
||||
|
||||
def _create_data(self, metadata, contents):
|
||||
def _create_data(self, metadata, contents):
|
||||
actions = contents['liveChatContinuation'].get('actions')
|
||||
if self.is_replay:
|
||||
if self.is_replay:
|
||||
interval = self._get_interval(actions)
|
||||
metadata.setdefault("timeoutMs",interval)
|
||||
"""Archived chat has different structures than live chat,
|
||||
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]
|
||||
for action in actions]
|
||||
else:
|
||||
metadata.setdefault('timeoutMs', 10000)
|
||||
chatdata = actions
|
||||
@@ -102,4 +113,9 @@ class Parser:
|
||||
return 0
|
||||
start = int(actions[0]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||
last = int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||
return (last - start)
|
||||
return (last - start)
|
||||
|
||||
def raise_exception(self, exception):
|
||||
if self.exception_holder is None:
|
||||
raise exception
|
||||
self.exception_holder = exception
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ class ChatProcessor:
|
||||
Abstract class that processes chat data.
|
||||
Receive chat data (actions) from Listener.
|
||||
'''
|
||||
|
||||
def process(self, chat_components: list):
|
||||
'''
|
||||
Interface that represents processing of chat data.
|
||||
Called from LiveChat object.
|
||||
|
||||
Called from LiveChat object.
|
||||
|
||||
Parameter
|
||||
----------
|
||||
chat_components: List[component]
|
||||
@@ -21,7 +22,9 @@ class ChatProcessor:
|
||||
'''
|
||||
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
|
||||
|
||||
|
||||
class Combinator(ChatProcessor):
|
||||
'''
|
||||
Combinator combines multiple chat processors.
|
||||
@@ -8,11 +9,11 @@ class Combinator(ChatProcessor):
|
||||
For example:
|
||||
[constructor]
|
||||
chat = LiveChat("video_id", processor = ( Processor1(), Processor2(), Processor3() ) )
|
||||
|
||||
|
||||
[receive return values]
|
||||
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.
|
||||
|
||||
Parameter
|
||||
@@ -34,6 +35,8 @@ class Combinator(ChatProcessor):
|
||||
Tuple of chat data processed by each chat processor.
|
||||
'''
|
||||
return tuple(processor.process(chat_components)
|
||||
for processor in self.processors)
|
||||
|
||||
for processor in self.processors)
|
||||
|
||||
def finalize(self, *args, **kwargs):
|
||||
[processor.finalize(*args, **kwargs)
|
||||
for processor in self.processors]
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import datetime
|
||||
import time
|
||||
from .renderer.textmessage import LiveChatTextMessageRenderer
|
||||
from .renderer.paidmessage import LiveChatPaidMessageRenderer
|
||||
from .renderer.paidsticker import LiveChatPaidStickerRenderer
|
||||
from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
|
||||
from .renderer.membership import LiveChatMembershipItemRenderer
|
||||
from .. chat_processor import ChatProcessor
|
||||
from ... import config
|
||||
logger = config.logger(__name__)
|
||||
|
||||
|
||||
class CompatibleProcessor(ChatProcessor):
|
||||
|
||||
def process(self, chat_components: list):
|
||||
|
||||
chatlist = []
|
||||
timeout = 0
|
||||
ret={}
|
||||
ret = {}
|
||||
ret["kind"] = "youtube#liveChatMessageListResponse"
|
||||
ret["etag"] = ""
|
||||
ret["nextPageToken"] = ""
|
||||
@@ -23,20 +23,24 @@ class CompatibleProcessor(ChatProcessor):
|
||||
for chat_component in chat_components:
|
||||
timeout += chat_component.get('timeout', 0)
|
||||
chatdata = chat_component.get('chatdata')
|
||||
|
||||
if chatdata is None: break
|
||||
|
||||
if chatdata is None:
|
||||
break
|
||||
for action in chatdata:
|
||||
if action is None: continue
|
||||
if action.get('addChatItemAction') is None: continue
|
||||
if action['addChatItemAction'].get('item') is None: continue
|
||||
if action is None:
|
||||
continue
|
||||
if action.get('addChatItemAction') is None:
|
||||
continue
|
||||
if action['addChatItemAction'].get('item') is None:
|
||||
continue
|
||||
|
||||
chat = self.parse(action)
|
||||
if chat:
|
||||
chatlist.append(chat)
|
||||
ret["pollingIntervalMillis"] = int(timeout*1000)
|
||||
ret["pageInfo"]={
|
||||
"totalResults":len(chatlist),
|
||||
"resultsPerPage":len(chatlist),
|
||||
ret["pollingIntervalMillis"] = int(timeout * 1000)
|
||||
ret["pageInfo"] = {
|
||||
"totalResults": len(chatlist),
|
||||
"resultsPerPage": len(chatlist),
|
||||
}
|
||||
ret["items"] = chatlist
|
||||
|
||||
@@ -47,35 +51,37 @@ class CompatibleProcessor(ChatProcessor):
|
||||
action = sitem.get("addChatItemAction")
|
||||
if action:
|
||||
item = action.get("item")
|
||||
if item is None: return None
|
||||
rd={}
|
||||
if item is None:
|
||||
return None
|
||||
rd = {}
|
||||
try:
|
||||
renderer = self.get_renderer(item)
|
||||
if renderer == None:
|
||||
if renderer is None:
|
||||
return None
|
||||
|
||||
rd["kind"] = "youtube#liveChatMessage"
|
||||
rd["etag"] = ""
|
||||
rd["id"] = 'LCC.' + renderer.get_id()
|
||||
rd["snippet"] = renderer.get_snippet()
|
||||
rd["snippet"] = renderer.get_snippet()
|
||||
rd["authorDetails"] = renderer.get_authordetails()
|
||||
except (KeyError,TypeError,AttributeError) as e:
|
||||
except (KeyError, TypeError, AttributeError) as e:
|
||||
logger.error(f"Error: {str(type(e))}-{str(e)}")
|
||||
logger.error(f"item: {sitem}")
|
||||
return None
|
||||
|
||||
return rd
|
||||
|
||||
return rd
|
||||
|
||||
def get_renderer(self, item):
|
||||
if item.get("liveChatTextMessageRenderer"):
|
||||
renderer = LiveChatTextMessageRenderer(item)
|
||||
elif item.get("liveChatPaidMessageRenderer"):
|
||||
renderer = LiveChatPaidMessageRenderer(item)
|
||||
elif item.get( "liveChatPaidStickerRenderer"):
|
||||
elif item.get("liveChatPaidStickerRenderer"):
|
||||
renderer = LiveChatPaidStickerRenderer(item)
|
||||
elif item.get("liveChatLegacyPaidMessageRenderer"):
|
||||
renderer = LiveChatLegacyPaidMessageRenderer(item)
|
||||
elif item.get("liveChatMembershipItemRenderer"):
|
||||
renderer = LiveChatMembershipItemRenderer(item)
|
||||
else:
|
||||
renderer = None
|
||||
return renderer
|
||||
|
||||
|
||||
@@ -1,68 +1,67 @@
|
||||
import datetime, pytz
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
class BaseRenderer:
|
||||
def __init__(self, item, chattype):
|
||||
self.renderer = list(item.values())[0]
|
||||
self.chattype = chattype
|
||||
|
||||
|
||||
def get_snippet(self):
|
||||
|
||||
message = self.get_message(self.renderer)
|
||||
|
||||
return {
|
||||
"type" : self.chattype,
|
||||
"liveChatId" : "",
|
||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
||||
"hasDisplayContent" : True,
|
||||
"displayMessage" : message,
|
||||
"type": self.chattype,
|
||||
"liveChatId": "",
|
||||
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||
"hasDisplayContent": True,
|
||||
"displayMessage": message,
|
||||
"textMessageDetails": {
|
||||
"messageText" : message
|
||||
"messageText": message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_authordetails(self):
|
||||
authorExternalChannelId = self.renderer.get("authorExternalChannelId")
|
||||
#parse subscriber type
|
||||
# parse subscriber type
|
||||
isVerified, isChatOwner, isChatSponsor, isChatModerator = (
|
||||
self.get_badges(self.renderer)
|
||||
)
|
||||
return {
|
||||
"channelId" : authorExternalChannelId,
|
||||
"channelUrl" : "http://www.youtube.com/channel/"+authorExternalChannelId,
|
||||
"displayName" : self.renderer["authorName"]["simpleText"],
|
||||
"profileImageUrl" : self.renderer["authorPhoto"]["thumbnails"][1]["url"] ,
|
||||
"isVerified" : isVerified,
|
||||
"isChatOwner" : isChatOwner,
|
||||
"isChatSponsor" : isChatSponsor,
|
||||
"isChatModerator" : isChatModerator
|
||||
}
|
||||
return {
|
||||
"channelId": authorExternalChannelId,
|
||||
"channelUrl": "http://www.youtube.com/channel/" + authorExternalChannelId,
|
||||
"displayName": self.renderer["authorName"]["simpleText"],
|
||||
"profileImageUrl": self.renderer["authorPhoto"]["thumbnails"][1]["url"],
|
||||
"isVerified": isVerified,
|
||||
"isChatOwner": isChatOwner,
|
||||
"isChatSponsor": isChatSponsor,
|
||||
"isChatModerator": isChatModerator
|
||||
}
|
||||
|
||||
|
||||
def get_message(self,renderer):
|
||||
def get_message(self, renderer):
|
||||
message = ''
|
||||
if renderer.get("message"):
|
||||
runs=renderer["message"].get("runs")
|
||||
runs = renderer["message"].get("runs")
|
||||
if runs:
|
||||
for r in runs:
|
||||
if r:
|
||||
if r.get('emoji'):
|
||||
message += r['emoji'].get('shortcuts',[''])[0]
|
||||
message += r['emoji'].get('shortcuts', [''])[0]
|
||||
else:
|
||||
message += r.get('text','')
|
||||
message += r.get('text', '')
|
||||
return message
|
||||
|
||||
def get_badges(self,renderer):
|
||||
def get_badges(self, renderer):
|
||||
isVerified = False
|
||||
isChatOwner = False
|
||||
isChatSponsor = False
|
||||
isChatModerator = False
|
||||
badges=renderer.get("authorBadges")
|
||||
badges = renderer.get("authorBadges")
|
||||
if badges:
|
||||
for badge in badges:
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
||||
if author_type == '確認済み':
|
||||
isVerified = True
|
||||
if author_type == '所有者':
|
||||
@@ -72,12 +71,11 @@ class BaseRenderer:
|
||||
if author_type == 'モデレーター':
|
||||
isChatModerator = True
|
||||
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
||||
|
||||
|
||||
def get_id(self):
|
||||
return self.renderer.get('id')
|
||||
|
||||
def get_publishedat(self,timestamp):
|
||||
dt = datetime.datetime.fromtimestamp(int(timestamp)/1000000)
|
||||
|
||||
def get_publishedat(self, timestamp):
|
||||
dt = datetime.datetime.fromtimestamp(int(timestamp) / 1000000)
|
||||
return dt.astimezone(pytz.utc).isoformat(
|
||||
timespec='milliseconds').replace('+00:00','Z')
|
||||
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
@@ -35,4 +35,4 @@ symbols = {
|
||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "newSponsorEvent")
|
||||
@@ -8,36 +10,33 @@ class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||
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,
|
||||
|
||||
"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
|
||||
# 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
|
||||
}
|
||||
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):
|
||||
def get_message(self, renderer):
|
||||
message = (renderer["eventText"]["runs"][0]["text"]
|
||||
)+' / '+(renderer["detailText"]["simpleText"])
|
||||
) + ' / ' + (renderer["detailText"]["simpleText"])
|
||||
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
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
|
||||
class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superChatEvent")
|
||||
@@ -10,32 +11,32 @@ class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||
def get_snippet(self):
|
||||
authorName = self.renderer["authorName"]["simpleText"]
|
||||
message = self.get_message(self.renderer)
|
||||
amountDisplayString, symbol, amountMicros =(
|
||||
amountDisplayString, symbol, amountMicros = (
|
||||
self.get_amountdata(self.renderer)
|
||||
)
|
||||
return {
|
||||
"type" : self.chattype,
|
||||
"liveChatId" : "",
|
||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
||||
"hasDisplayContent" : True,
|
||||
"displayMessage" : amountDisplayString+" from "+authorName+': \"'+ message+'\"',
|
||||
"superChatDetails" : {
|
||||
"amountMicros" : amountMicros,
|
||||
"currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||
"amountDisplayString" : amountDisplayString,
|
||||
"tier" : 0,
|
||||
"backgroundColor" : self.renderer.get("bodyBackgroundColor", 0)
|
||||
"type": self.chattype,
|
||||
"liveChatId": "",
|
||||
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||
"hasDisplayContent": True,
|
||||
"displayMessage": amountDisplayString + " from " + authorName + ': \"' + message + '\"',
|
||||
"superChatDetails": {
|
||||
"amountMicros": amountMicros,
|
||||
"currency": currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||
"amountDisplayString": amountDisplayString,
|
||||
"tier": 0,
|
||||
"backgroundColor": self.renderer.get("bodyBackgroundColor", 0)
|
||||
}
|
||||
}
|
||||
|
||||
def get_amountdata(self,renderer):
|
||||
def get_amountdata(self, renderer):
|
||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(amountDisplayString)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amountMicros = int(float(m.group(2).replace(',',''))*1000000)
|
||||
amountMicros = int(float(m.group(2).replace(',', '')) * 1000000)
|
||||
else:
|
||||
symbol = ""
|
||||
amountMicros = 0
|
||||
return amountDisplayString, symbol, amountMicros
|
||||
return amountDisplayString, symbol, amountMicros
|
||||
|
||||
@@ -3,46 +3,45 @@ from . import currency
|
||||
from .base import BaseRenderer
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
|
||||
class LiveChatPaidStickerRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superStickerEvent")
|
||||
|
||||
def get_snippet(self):
|
||||
authorName = self.renderer["authorName"]["simpleText"]
|
||||
amountDisplayString, symbol, amountMicros =(
|
||||
amountDisplayString, symbol, amountMicros = (
|
||||
self.get_amountdata(self.renderer)
|
||||
)
|
||||
|
||||
return {
|
||||
"type" : self.chattype,
|
||||
"liveChatId" : "",
|
||||
"authorChannelId" : self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt" : self.get_publishedat(self.renderer.get("timestampUsec",0)),
|
||||
"hasDisplayContent" : True,
|
||||
"displayMessage" : "Super Sticker " + amountDisplayString + " from "+authorName,
|
||||
"superStickerDetails" : {
|
||||
"superStickerMetaData" : {
|
||||
"type": self.chattype,
|
||||
"liveChatId": "",
|
||||
"authorChannelId": self.renderer.get("authorExternalChannelId"),
|
||||
"publishedAt": self.get_publishedat(self.renderer.get("timestampUsec", 0)),
|
||||
"hasDisplayContent": True,
|
||||
"displayMessage": "Super Sticker " + amountDisplayString + " from " + authorName,
|
||||
"superStickerDetails": {
|
||||
"superStickerMetaData": {
|
||||
"stickerId": "",
|
||||
"altText": "",
|
||||
"language": ""
|
||||
"language": ""
|
||||
},
|
||||
"amountMicros" : amountMicros,
|
||||
"currency" : currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||
"amountDisplayString" : amountDisplayString,
|
||||
"tier" : 0,
|
||||
"backgroundColor" : self.renderer.get("bodyBackgroundColor", 0)
|
||||
"amountMicros": amountMicros,
|
||||
"currency": currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol,
|
||||
"amountDisplayString": amountDisplayString,
|
||||
"tier": 0,
|
||||
"backgroundColor": self.renderer.get("bodyBackgroundColor", 0)
|
||||
}
|
||||
}
|
||||
|
||||
def get_amountdata(self,renderer):
|
||||
def get_amountdata(self, renderer):
|
||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(amountDisplayString)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amountMicros = int(float(m.group(2).replace(',',''))*1000000)
|
||||
amountMicros = int(float(m.group(2).replace(',', '')) * 1000000)
|
||||
else:
|
||||
symbol = ""
|
||||
amountMicros = 0
|
||||
return amountDisplayString, symbol, amountMicros
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "textMessageEvent")
|
||||
|
||||
11
pytchat/processors/default/custom_encoder.py
Normal file
11
pytchat/processors/default/custom_encoder.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import json
|
||||
from .renderer.base import Author
|
||||
from .renderer.paidmessage import Colors
|
||||
from .renderer.paidsticker import Colors2
|
||||
|
||||
|
||||
class CustomEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, Author) or isinstance(obj, Colors) or isinstance(obj, Colors2):
|
||||
return vars(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
@@ -1,31 +1,132 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from .custom_encoder import CustomEncoder
|
||||
from .renderer.textmessage import LiveChatTextMessageRenderer
|
||||
from .renderer.paidmessage import LiveChatPaidMessageRenderer
|
||||
from .renderer.paidsticker import LiveChatPaidStickerRenderer
|
||||
from .renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
|
||||
from .renderer.membership import LiveChatMembershipItemRenderer
|
||||
from .. chat_processor import ChatProcessor
|
||||
from ... import config
|
||||
|
||||
logger = config.logger(__name__)
|
||||
|
||||
|
||||
class Chat:
|
||||
def json(self) -> str:
|
||||
return json.dumps(vars(self), ensure_ascii=False, cls=CustomEncoder)
|
||||
|
||||
|
||||
class Chatdata:
|
||||
def __init__(self,chatlist:list, timeout:float):
|
||||
|
||||
def __init__(self, chatlist: list, timeout: float, abs_diff):
|
||||
self.items = chatlist
|
||||
self.interval = timeout
|
||||
|
||||
self.abs_diff = abs_diff
|
||||
self.itemcount = 0
|
||||
|
||||
def tick(self):
|
||||
if self.interval == 0:
|
||||
'''DEPRECATE
|
||||
Use sync_items()
|
||||
'''
|
||||
if len(self.items) < 1:
|
||||
time.sleep(1)
|
||||
return
|
||||
time.sleep(self.interval/len(self.items))
|
||||
if self.itemcount == 0:
|
||||
self.starttime = time.time()
|
||||
if len(self.items) == 1:
|
||||
total_itemcount = 1
|
||||
else:
|
||||
total_itemcount = len(self.items) - 1
|
||||
next_chattime = (self.items[0].timestamp + (self.items[-1].timestamp - self.items[0].timestamp) / total_itemcount * self.itemcount) / 1000
|
||||
tobe_disptime = self.abs_diff + next_chattime
|
||||
wait_sec = tobe_disptime - time.time()
|
||||
self.itemcount += 1
|
||||
|
||||
if wait_sec < 0:
|
||||
wait_sec = 0
|
||||
|
||||
time.sleep(wait_sec)
|
||||
|
||||
async def tick_async(self):
|
||||
if self.interval == 0:
|
||||
'''DEPRECATE
|
||||
Use async_items()
|
||||
'''
|
||||
if len(self.items) < 1:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
await asyncio.sleep(self.interval/len(self.items))
|
||||
if self.itemcount == 0:
|
||||
self.starttime = time.time()
|
||||
if len(self.items) == 1:
|
||||
total_itemcount = 1
|
||||
else:
|
||||
total_itemcount = len(self.items) - 1
|
||||
next_chattime = (self.items[0].timestamp + (self.items[-1].timestamp - self.items[0].timestamp) / total_itemcount * self.itemcount) / 1000
|
||||
tobe_disptime = self.abs_diff + next_chattime
|
||||
wait_sec = tobe_disptime - time.time()
|
||||
self.itemcount += 1
|
||||
|
||||
if wait_sec < 0:
|
||||
wait_sec = 0
|
||||
|
||||
await asyncio.sleep(wait_sec)
|
||||
|
||||
def sync_items(self):
|
||||
starttime = time.time()
|
||||
if len(self.items) > 0:
|
||||
last_chattime = self.items[-1].timestamp / 1000
|
||||
tobe_disptime = self.abs_diff + last_chattime
|
||||
wait_total_sec = max(tobe_disptime - time.time(), 0)
|
||||
if len(self.items) > 1:
|
||||
wait_sec = wait_total_sec / len(self.items)
|
||||
elif len(self.items) == 1:
|
||||
wait_sec = 0
|
||||
for c in self.items:
|
||||
if wait_sec < 0:
|
||||
wait_sec = 0
|
||||
time.sleep(wait_sec)
|
||||
yield c
|
||||
stop_interval = time.time() - starttime
|
||||
if stop_interval < 1:
|
||||
time.sleep(1 - stop_interval)
|
||||
|
||||
async def async_items(self):
|
||||
starttime = time.time()
|
||||
if len(self.items) > 0:
|
||||
last_chattime = self.items[-1].timestamp / 1000
|
||||
tobe_disptime = self.abs_diff + last_chattime
|
||||
wait_total_sec = max(tobe_disptime - time.time(), 0)
|
||||
if len(self.items) > 1:
|
||||
wait_sec = wait_total_sec / len(self.items)
|
||||
elif len(self.items) == 1:
|
||||
wait_sec = 0
|
||||
for c in self.items:
|
||||
if wait_sec < 0:
|
||||
wait_sec = 0
|
||||
await asyncio.sleep(wait_sec)
|
||||
yield c
|
||||
|
||||
stop_interval = time.time() - starttime
|
||||
if stop_interval < 1:
|
||||
await asyncio.sleep(1 - stop_interval)
|
||||
|
||||
def json(self) -> str:
|
||||
return ''.join(("[", ','.join((a.json() for a in self.items)), "]"))
|
||||
|
||||
|
||||
class DefaultProcessor(ChatProcessor):
|
||||
def __init__(self):
|
||||
self.first = True
|
||||
self.abs_diff = 0
|
||||
self.renderers = {
|
||||
"liveChatTextMessageRenderer": LiveChatTextMessageRenderer(),
|
||||
"liveChatPaidMessageRenderer": LiveChatPaidMessageRenderer(),
|
||||
"liveChatPaidStickerRenderer": LiveChatPaidStickerRenderer(),
|
||||
"liveChatLegacyPaidMessageRenderer": LiveChatLegacyPaidMessageRenderer(),
|
||||
"liveChatMembershipItemRenderer": LiveChatMembershipItemRenderer()
|
||||
}
|
||||
|
||||
def process(self, chat_components: list):
|
||||
|
||||
chatlist = []
|
||||
@@ -33,47 +134,46 @@ class DefaultProcessor(ChatProcessor):
|
||||
|
||||
if chat_components:
|
||||
for component in chat_components:
|
||||
if component is None:
|
||||
continue
|
||||
timeout += component.get('timeout', 0)
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata is None: continue
|
||||
chatdata = component.get('chatdata') # if from Extractor, chatdata is generator.
|
||||
if chatdata is None:
|
||||
continue
|
||||
for action in chatdata:
|
||||
if action is None: continue
|
||||
if action.get('addChatItemAction') is None: continue
|
||||
if action['addChatItemAction'].get('item') is None: continue
|
||||
|
||||
chat = self._parse(action)
|
||||
if action is None:
|
||||
continue
|
||||
if action.get('addChatItemAction') is None:
|
||||
continue
|
||||
item = action['addChatItemAction'].get('item')
|
||||
if item is None:
|
||||
continue
|
||||
chat = self._parse(item)
|
||||
if chat:
|
||||
chatlist.append(chat)
|
||||
return Chatdata(chatlist, float(timeout))
|
||||
|
||||
|
||||
if self.first and chatlist:
|
||||
self.abs_diff = time.time() - chatlist[0].timestamp / 1000
|
||||
self.first = False
|
||||
|
||||
def _parse(self, sitem):
|
||||
chatdata = Chatdata(chatlist, float(timeout), self.abs_diff)
|
||||
|
||||
action = sitem.get("addChatItemAction")
|
||||
if action:
|
||||
item = action.get("item")
|
||||
if item is None: return None
|
||||
return chatdata
|
||||
|
||||
def _parse(self, item):
|
||||
try:
|
||||
renderer = self._get_renderer(item)
|
||||
if renderer == None:
|
||||
key = list(item.keys())[0]
|
||||
renderer = self.renderers.get(key)
|
||||
if renderer is None:
|
||||
return None
|
||||
|
||||
renderer.setitem(item.get(key), Chat())
|
||||
renderer.settype()
|
||||
renderer.get_snippet()
|
||||
renderer.get_authordetails()
|
||||
except (KeyError,TypeError) as e:
|
||||
logger.error(f"{str(type(e))}-{str(e)} sitem:{str(sitem)}")
|
||||
rendered_chatobj = renderer.get_chatobj()
|
||||
renderer.clear()
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.error(f"{str(type(e))}-{str(e)} item:{str(item)}")
|
||||
return None
|
||||
return renderer
|
||||
|
||||
def _get_renderer(self, item):
|
||||
if item.get("liveChatTextMessageRenderer"):
|
||||
renderer = LiveChatTextMessageRenderer(item)
|
||||
elif item.get("liveChatPaidMessageRenderer"):
|
||||
renderer = LiveChatPaidMessageRenderer(item)
|
||||
elif item.get( "liveChatPaidStickerRenderer"):
|
||||
renderer = LiveChatPaidStickerRenderer(item)
|
||||
elif item.get("liveChatLegacyPaidMessageRenderer"):
|
||||
renderer = LiveChatLegacyPaidMessageRenderer(item)
|
||||
else:
|
||||
renderer = None
|
||||
return renderer
|
||||
|
||||
return rendered_chatobj
|
||||
|
||||
@@ -1,93 +1,101 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Author:
|
||||
pass
|
||||
|
||||
|
||||
class BaseRenderer:
|
||||
def __init__(self, item, chattype):
|
||||
self.renderer = list(item.values())[0]
|
||||
self.chattype = chattype
|
||||
self.author = Author()
|
||||
def setitem(self, item, chat):
|
||||
self.item = item
|
||||
self.chat = chat
|
||||
self.chat.author = Author()
|
||||
|
||||
def settype(self):
|
||||
pass
|
||||
|
||||
def get_snippet(self):
|
||||
self.type = self.chattype
|
||||
self.id = self.renderer.get('id')
|
||||
timestampUsec = int(self.renderer.get("timestampUsec",0))
|
||||
self.timestamp = int(timestampUsec/1000)
|
||||
tst = self.renderer.get("timestampText")
|
||||
self.chat.id = self.item.get('id')
|
||||
timestampUsec = int(self.item.get("timestampUsec", 0))
|
||||
self.chat.timestamp = int(timestampUsec / 1000)
|
||||
tst = self.item.get("timestampText")
|
||||
if tst:
|
||||
self.elapsedTime = tst.get("simpleText")
|
||||
self.chat.elapsedTime = tst.get("simpleText")
|
||||
else:
|
||||
self.elapsedTime = ""
|
||||
self.datetime = self.get_datetime(timestampUsec)
|
||||
self.message ,self.messageEx = self.get_message(self.renderer)
|
||||
self.id = self.renderer.get('id')
|
||||
self.amountValue= 0.0
|
||||
self.amountString = ""
|
||||
self.currency= ""
|
||||
self.bgColor = 0
|
||||
self.chat.elapsedTime = ""
|
||||
self.chat.datetime = self.get_datetime(timestampUsec)
|
||||
self.chat.message, self.chat.messageEx = self.get_message(self.item)
|
||||
self.chat.id = self.item.get('id')
|
||||
self.chat.amountValue = 0.0
|
||||
self.chat.amountString = ""
|
||||
self.chat.currency = ""
|
||||
self.chat.bgColor = 0
|
||||
|
||||
def get_authordetails(self):
|
||||
self.author.badgeUrl = ""
|
||||
(self.author.isVerified,
|
||||
self.author.isChatOwner,
|
||||
self.author.isChatSponsor,
|
||||
self.author.isChatModerator) = (
|
||||
self.get_badges(self.renderer)
|
||||
self.chat.author.badgeUrl = ""
|
||||
(self.chat.author.isVerified,
|
||||
self.chat.author.isChatOwner,
|
||||
self.chat.author.isChatSponsor,
|
||||
self.chat.author.isChatModerator) = (
|
||||
self.get_badges(self.item)
|
||||
)
|
||||
self.author.channelId = self.renderer.get("authorExternalChannelId")
|
||||
self.author.channelUrl = "http://www.youtube.com/channel/"+self.author.channelId
|
||||
self.author.name = self.renderer["authorName"]["simpleText"]
|
||||
self.author.imageUrl= self.renderer["authorPhoto"]["thumbnails"][1]["url"]
|
||||
|
||||
self.chat.author.channelId = self.item.get("authorExternalChannelId")
|
||||
self.chat.author.channelUrl = "http://www.youtube.com/channel/" + self.chat.author.channelId
|
||||
self.chat.author.name = self.item["authorName"]["simpleText"]
|
||||
self.chat.author.imageUrl = self.item["authorPhoto"]["thumbnails"][1]["url"]
|
||||
|
||||
|
||||
def get_message(self,renderer):
|
||||
def get_message(self, item):
|
||||
message = ''
|
||||
message_ex = []
|
||||
if renderer.get("message"):
|
||||
runs=renderer["message"].get("runs")
|
||||
if runs:
|
||||
for r in runs:
|
||||
if r:
|
||||
if r.get('emoji'):
|
||||
message += r['emoji'].get('shortcuts',[''])[0]
|
||||
message_ex.append(r['emoji']['image']['thumbnails'][1].get('url'))
|
||||
else:
|
||||
message += r.get('text','')
|
||||
message_ex.append(r.get('text',''))
|
||||
runs = item.get("message", {}).get("runs", {})
|
||||
for r in runs:
|
||||
if not hasattr(r, "get"):
|
||||
continue
|
||||
if r.get('emoji'):
|
||||
message += r['emoji'].get('shortcuts', [''])[0]
|
||||
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:
|
||||
message += r.get('text', '')
|
||||
message_ex.append(r.get('text', ''))
|
||||
return message, message_ex
|
||||
|
||||
|
||||
|
||||
def get_badges(self,renderer):
|
||||
self.author.type = ''
|
||||
def get_badges(self, renderer):
|
||||
self.chat.author.type = ''
|
||||
isVerified = False
|
||||
isChatOwner = False
|
||||
isChatSponsor = False
|
||||
isChatModerator = False
|
||||
badges=renderer.get("authorBadges")
|
||||
if badges:
|
||||
for badge in badges:
|
||||
if badge["liveChatAuthorBadgeRenderer"].get("icon"):
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["icon"]["iconType"]
|
||||
self.author.type = author_type
|
||||
if author_type == 'VERIFIED':
|
||||
isVerified = True
|
||||
if author_type == 'OWNER':
|
||||
isChatOwner = True
|
||||
if author_type == 'MODERATOR':
|
||||
isChatModerator = True
|
||||
if badge["liveChatAuthorBadgeRenderer"].get("customThumbnail"):
|
||||
isChatSponsor = True
|
||||
self.author.type = 'MEMBER'
|
||||
self.get_badgeurl(badge)
|
||||
badges = renderer.get("authorBadges", {})
|
||||
for badge in badges:
|
||||
if badge["liveChatAuthorBadgeRenderer"].get("icon"):
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["icon"]["iconType"]
|
||||
self.chat.author.type = author_type
|
||||
if author_type == 'VERIFIED':
|
||||
isVerified = True
|
||||
if author_type == 'OWNER':
|
||||
isChatOwner = True
|
||||
if author_type == 'MODERATOR':
|
||||
isChatModerator = True
|
||||
if badge["liveChatAuthorBadgeRenderer"].get("customThumbnail"):
|
||||
isChatSponsor = True
|
||||
self.chat.author.type = 'MEMBER'
|
||||
self.get_badgeurl(badge)
|
||||
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
||||
|
||||
|
||||
def get_badgeurl(self,badge):
|
||||
self.author.badgeUrl = badge["liveChatAuthorBadgeRenderer"]["customThumbnail"]["thumbnails"][0]["url"]
|
||||
def get_badgeurl(self, badge):
|
||||
self.chat.author.badgeUrl = badge["liveChatAuthorBadgeRenderer"]["customThumbnail"]["thumbnails"][0]["url"]
|
||||
|
||||
def get_datetime(self, timestamp):
|
||||
dt = datetime.fromtimestamp(timestamp / 1000000)
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def get_chatobj(self):
|
||||
return self.chat
|
||||
|
||||
def get_datetime(self,timestamp):
|
||||
dt = datetime.fromtimestamp(timestamp/1000000)
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
def clear(self):
|
||||
self.item = None
|
||||
self.chat = None
|
||||
|
||||
@@ -35,4 +35,4 @@ symbols = {
|
||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
from .base import BaseRenderer
|
||||
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "newSponsor")
|
||||
|
||||
|
||||
|
||||
class LiveChatLegacyPaidMessageRenderer(BaseRenderer):
|
||||
def settype(self):
|
||||
self.chat.type = "newSponsor"
|
||||
|
||||
def get_authordetails(self):
|
||||
super().get_authordetails()
|
||||
self.author.isChatSponsor = True
|
||||
|
||||
|
||||
def get_message(self,renderer):
|
||||
message = (renderer["eventText"]["runs"][0]["text"]
|
||||
)+' / '+(renderer["detailText"]["simpleText"])
|
||||
return message
|
||||
|
||||
self.chat.author.isChatSponsor = True
|
||||
|
||||
def get_message(self, item):
|
||||
message = (item["eventText"]["runs"][0]["text"]
|
||||
) + ' / ' + (item["detailText"]["simpleText"])
|
||||
return message, [message]
|
||||
|
||||
18
pytchat/processors/default/renderer/membership.py
Normal file
18
pytchat/processors/default/renderer/membership.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class LiveChatMembershipItemRenderer(BaseRenderer):
|
||||
def settype(self):
|
||||
self.chat.type = "newSponsor"
|
||||
|
||||
def get_authordetails(self):
|
||||
super().get_authordetails()
|
||||
self.chat.author.isChatSponsor = True
|
||||
|
||||
def get_message(self, item):
|
||||
try:
|
||||
message = ''.join([mes.get("text", "")
|
||||
for mes in item["headerSubtext"]["runs"]])
|
||||
except KeyError:
|
||||
return "Welcome New Member!", ["Welcome New Member!"]
|
||||
return message, [message]
|
||||
@@ -3,30 +3,45 @@ from . import currency
|
||||
from .base import BaseRenderer
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superChat")
|
||||
|
||||
class Colors:
|
||||
pass
|
||||
|
||||
|
||||
class LiveChatPaidMessageRenderer(BaseRenderer):
|
||||
def settype(self):
|
||||
self.chat.type = "superChat"
|
||||
|
||||
def get_snippet(self):
|
||||
super().get_snippet()
|
||||
amountDisplayString, symbol, amount =(
|
||||
self.get_amountdata(self.renderer)
|
||||
amountDisplayString, symbol, amount = (
|
||||
self.get_amountdata(self.item)
|
||||
)
|
||||
self.amountValue= amount
|
||||
self.amountString = amountDisplayString
|
||||
self.currency= currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol
|
||||
self.bgColor= self.renderer.get("bodyBackgroundColor", 0)
|
||||
|
||||
self.chat.amountValue = amount
|
||||
self.chat.amountString = amountDisplayString
|
||||
self.chat.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(
|
||||
symbol) else symbol
|
||||
self.chat.bgColor = self.item.get("bodyBackgroundColor", 0)
|
||||
self.chat.colors = self.get_colors()
|
||||
|
||||
|
||||
def get_amountdata(self,renderer):
|
||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||
def get_amountdata(self, item):
|
||||
amountDisplayString = item["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(amountDisplayString)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amount = float(m.group(2).replace(',',''))
|
||||
amount = float(m.group(2).replace(',', ''))
|
||||
else:
|
||||
symbol = ""
|
||||
amount = 0.0
|
||||
return amountDisplayString, symbol, amount
|
||||
return amountDisplayString, symbol, amount
|
||||
|
||||
def get_colors(self):
|
||||
item = self.item
|
||||
colors = Colors()
|
||||
colors.headerBackgroundColor = item.get("headerBackgroundColor", 0)
|
||||
colors.headerTextColor = item.get("headerTextColor", 0)
|
||||
colors.bodyBackgroundColor = item.get("bodyBackgroundColor", 0)
|
||||
colors.bodyTextColor = item.get("bodyTextColor", 0)
|
||||
colors.timestampColor = item.get("timestampColor", 0)
|
||||
colors.authorNameTextColor = item.get("authorNameTextColor", 0)
|
||||
return colors
|
||||
|
||||
@@ -3,37 +3,45 @@ from . import currency
|
||||
from .base import BaseRenderer
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
class LiveChatPaidStickerRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superSticker")
|
||||
|
||||
class Colors2:
|
||||
pass
|
||||
|
||||
|
||||
class LiveChatPaidStickerRenderer(BaseRenderer):
|
||||
def settype(self):
|
||||
self.chat.type = "superSticker"
|
||||
|
||||
def get_snippet(self):
|
||||
super().get_snippet()
|
||||
amountDisplayString, symbol, amount =(
|
||||
self.get_amountdata(self.renderer)
|
||||
amountDisplayString, symbol, amount = (
|
||||
self.get_amountdata(self.item)
|
||||
)
|
||||
self.amountValue = amount
|
||||
self.amountString = amountDisplayString
|
||||
self.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol
|
||||
self.bgColor = self.renderer.get("moneyChipBackgroundColor", 0)
|
||||
self.sticker = "https:"+self.renderer["sticker"]["thumbnails"][0]["url"]
|
||||
|
||||
self.chat.amountValue = amount
|
||||
self.chat.amountString = amountDisplayString
|
||||
self.chat.currency = currency.symbols[symbol]["fxtext"] if currency.symbols.get(
|
||||
symbol) else symbol
|
||||
self.chat.bgColor = self.item.get("backgroundColor", 0)
|
||||
self.chat.sticker = "".join(("https:",
|
||||
self.item["sticker"]["thumbnails"][0]["url"]))
|
||||
self.chat.colors = self.get_colors()
|
||||
|
||||
|
||||
def get_amountdata(self,renderer):
|
||||
amountDisplayString = renderer["purchaseAmountText"]["simpleText"]
|
||||
def get_amountdata(self, item):
|
||||
amountDisplayString = item["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(amountDisplayString)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amount = float(m.group(2).replace(',',''))
|
||||
amount = float(m.group(2).replace(',', ''))
|
||||
else:
|
||||
symbol = ""
|
||||
amount = 0.0
|
||||
return amountDisplayString, symbol, amount
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def get_colors(self):
|
||||
item = self.item
|
||||
colors = Colors2()
|
||||
colors.moneyChipBackgroundColor = item.get("moneyChipBackgroundColor", 0)
|
||||
colors.moneyChipTextColor = item.get("moneyChipTextColor", 0)
|
||||
colors.backgroundColor = item.get("backgroundColor", 0)
|
||||
colors.authorNameTextColor = item.get("authorNameTextColor", 0)
|
||||
return colors
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class LiveChatTextMessageRenderer(BaseRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "textMessage")
|
||||
def settype(self):
|
||||
self.chat.type = "textMessage"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class DummyProcessor(ChatProcessor):
|
||||
'''
|
||||
Dummy processor just returns received chat_components directly.
|
||||
'''
|
||||
|
||||
def process(self, chat_components: list):
|
||||
return chat_components
|
||||
|
||||
@@ -1,98 +1,170 @@
|
||||
import csv
|
||||
import httpx
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from base64 import standard_b64encode
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from .chat_processor import ChatProcessor
|
||||
from .default.processor import DefaultProcessor
|
||||
from ..exceptions import UnknownConnectionError
|
||||
import tempfile
|
||||
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
fmt_headers = ['datetime','elapsed','authorName','message','superchat'
|
||||
,'type','authorChannel']
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
|
||||
fmt_headers = ['datetime', 'elapsed', 'authorName',
|
||||
'message', 'superchat', 'type', 'authorChannel']
|
||||
|
||||
HEADER_HTML = '''
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
|
||||
<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.
|
||||
HTMLArchiver saves chat data as HTML table format.
|
||||
'''
|
||||
|
||||
def __init__(self, save_path):
|
||||
def __init__(self, save_path, callback=None):
|
||||
super().__init__()
|
||||
self.client = httpx.Client(http2=True)
|
||||
self.save_path = self._checkpath(save_path)
|
||||
with open(self.save_path, mode='a', encoding = 'utf-8') as f:
|
||||
f.write(HEADER_HTML)
|
||||
f.write('<table border="1" style="border-collapse: collapse">')
|
||||
f.writelines(self._parse_html_header(fmt_headers))
|
||||
self.processor = DefaultProcessor()
|
||||
self.emoji_table = {} # dict for custom emojis. key: emoji_id, value: base64 encoded image binary.
|
||||
self.callback = callback
|
||||
self.executor = ThreadPoolExecutor(max_workers=10)
|
||||
self.tmp_fp = tempfile.NamedTemporaryFile(mode="a", encoding="utf-8", delete=False)
|
||||
self.tmp_filename = self.tmp_fp.name
|
||||
self.counter = 0
|
||||
|
||||
def _checkpath(self, filepath):
|
||||
splitter = os.path.splitext(os.path.basename(filepath))
|
||||
body = splitter[0]
|
||||
extention = splitter[1]
|
||||
newpath = filepath
|
||||
counter = 0
|
||||
counter = 1
|
||||
while os.path.exists(newpath):
|
||||
match = re.search(PATTERN,body)
|
||||
match = re.search(PATTERN, body)
|
||||
if match:
|
||||
counter=int(match[2])+1
|
||||
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)
|
||||
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||
return newpath
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
dict :
|
||||
save_path : str :
|
||||
Actual save path of file.
|
||||
total_lines : int :
|
||||
count of total lines written to the file.
|
||||
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:
|
||||
chats = self.processor.process(chat_components).items
|
||||
for c in chats:
|
||||
f.writelines(
|
||||
self._parse_html_line([
|
||||
c.datetime,
|
||||
c.elapsedTime,
|
||||
c.author.name,
|
||||
c.message,
|
||||
c.amountString,
|
||||
c.author.type,
|
||||
c.author.channelId]
|
||||
)
|
||||
if chat_components is None or len(chat_components) == 0:
|
||||
return self.save_path ,self.counter
|
||||
for c in self.processor.process(chat_components).items:
|
||||
self.tmp_fp.write(
|
||||
self._parse_html_line((
|
||||
c.datetime,
|
||||
c.elapsedTime,
|
||||
c.author.name,
|
||||
self._parse_message(c.messageEx),
|
||||
c.amountString,
|
||||
c.author.type,
|
||||
c.author.channelId)
|
||||
)
|
||||
'''
|
||||
#Palliative treatment#
|
||||
Comment out below line to prevent the table
|
||||
display from collapsing.
|
||||
'''
|
||||
#f.write('</table>')
|
||||
)
|
||||
if self.callback:
|
||||
self.callback(None, 1)
|
||||
self.counter += 1
|
||||
return self.save_path, self.counter
|
||||
|
||||
def _parse_html_line(self, raw_line):
|
||||
html = ''
|
||||
html+=' <tr>'
|
||||
for cell in raw_line:
|
||||
html+='<td>'+cell+'</td>'
|
||||
html+='</tr>\n'
|
||||
return html
|
||||
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):
|
||||
err = None
|
||||
for _ in range(5):
|
||||
try:
|
||||
resp = self.client.get(url, timeout=30)
|
||||
break
|
||||
except httpx.HTTPError as e:
|
||||
err = e
|
||||
time.sleep(3)
|
||||
else:
|
||||
raise UnknownConnectionError(str(err))
|
||||
|
||||
return standard_b64encode(resp.content).decode()
|
||||
|
||||
def _set_emoji_table(self, item: dict):
|
||||
emoji_id = ''.join(('Z', item['id'])) if 48 <= ord(item['id'][0]) <= 57 else item['id']
|
||||
if emoji_id not in self.emoji_table:
|
||||
self.emoji_table.setdefault(emoji_id, self.executor.submit(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 _parse_html_header(self,raw_line):
|
||||
html = ''
|
||||
html+='<thead>\n'
|
||||
html+=' <tr>'
|
||||
for cell in raw_line:
|
||||
html+='<th>'+cell+'</th>'
|
||||
html+='</tr>\n'
|
||||
html+='</thead>\n'
|
||||
return html
|
||||
|
||||
def _create_styles(self):
|
||||
return '\n'.join(('<style type="text/css">',
|
||||
TABLE_CSS,
|
||||
'\n'.join(self._stylecode(key, self.emoji_table[key].result(), 24, 24)
|
||||
for key in self.emoji_table.keys()),
|
||||
'</style>\n'))
|
||||
|
||||
def finalize(self):
|
||||
if self.tmp_fp:
|
||||
self.tmp_fp.flush()
|
||||
self.tmp_fp = None
|
||||
with open(self.save_path, mode='w', encoding='utf-8') as outfile:
|
||||
# write header
|
||||
outfile.writelines((
|
||||
HEADER_HTML, self._create_styles(), '</head>\n',
|
||||
'<body>\n', '<table class="css">\n',
|
||||
self._parse_table_header(fmt_headers)))
|
||||
# write body
|
||||
fp = open(self.tmp_filename, mode="r", encoding="utf-8")
|
||||
for line in fp:
|
||||
outfile.write(line)
|
||||
outfile.write('</table>\n</body>\n</html>')
|
||||
fp.close()
|
||||
os.remove(self.tmp_filename)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
|
||||
|
||||
class JsonfileArchiver(ChatProcessor):
|
||||
"""
|
||||
@@ -13,39 +13,44 @@ class JsonfileArchiver(ChatProcessor):
|
||||
Parameter:
|
||||
----------
|
||||
save_path : str :
|
||||
save path of file.If a file with the same name exists,
|
||||
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):
|
||||
|
||||
def __init__(self, save_path):
|
||||
super().__init__()
|
||||
self.save_path = self._checkpath(save_path)
|
||||
self.line_counter = 0
|
||||
|
||||
def process(self,chat_components: list):
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
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:
|
||||
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
|
||||
if component is None:
|
||||
continue
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata is None: continue
|
||||
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 }
|
||||
|
||||
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]
|
||||
@@ -53,14 +58,12 @@ class JsonfileArchiver(ChatProcessor):
|
||||
newpath = filepath
|
||||
counter = 0
|
||||
while os.path.exists(newpath):
|
||||
match = re.search(PATTERN,body)
|
||||
match = re.search(PATTERN, body)
|
||||
if match:
|
||||
counter=int(match[2])+1
|
||||
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)
|
||||
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
|
||||
##version 2
|
||||
|
||||
|
||||
class SimpleDisplayProcessor(ChatProcessor):
|
||||
|
||||
|
||||
def process(self, chat_components: list):
|
||||
chatlist = []
|
||||
timeout = 0
|
||||
|
||||
|
||||
if chat_components is None:
|
||||
return {"timeout":timeout, "chatlist":chatlist}
|
||||
return {"timeout": timeout, "chatlist": chatlist}
|
||||
for component in chat_components:
|
||||
timeout += component.get('timeout', 0)
|
||||
chatdata = component.get('chatdata')
|
||||
|
||||
if chatdata is None:break
|
||||
for action in chatdata:
|
||||
if action is None:continue
|
||||
if action.get('addChatItemAction') is None:continue
|
||||
if action['addChatItemAction'].get('item') is None:continue
|
||||
|
||||
root = action['addChatItemAction']['item'].get('liveChatTextMessageRenderer')
|
||||
|
||||
if chatdata is None:
|
||||
break
|
||||
for action in chatdata:
|
||||
if action is None:
|
||||
continue
|
||||
if action.get('addChatItemAction') is None:
|
||||
continue
|
||||
if action['addChatItemAction'].get('item') is None:
|
||||
continue
|
||||
|
||||
root = action['addChatItemAction']['item'].get(
|
||||
'liveChatTextMessageRenderer')
|
||||
|
||||
if root:
|
||||
author_name = root['authorName']['simpleText']
|
||||
message = self._parse_message(root.get('message'))
|
||||
purchase_amount_text = ''
|
||||
else:
|
||||
root = ( action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer') or
|
||||
action['addChatItemAction']['item'].get('liveChatPaidStickerRenderer') )
|
||||
root = (action['addChatItemAction']['item'].get('liveChatPaidMessageRenderer')
|
||||
or action['addChatItemAction']['item'].get('liveChatPaidStickerRenderer'))
|
||||
if root:
|
||||
author_name = root['authorName']['simpleText']
|
||||
message = self._parse_message(root.get('message'))
|
||||
purchase_amount_text = root['purchaseAmountText']['simpleText']
|
||||
else:
|
||||
continue
|
||||
chatlist.append(f'[{author_name}]: {message} {purchase_amount_text}')
|
||||
return {"timeout":timeout, "chatlist":chatlist}
|
||||
|
||||
def _parse_message(self,message):
|
||||
chatlist.append(
|
||||
f'[{author_name}]: {message} {purchase_amount_text}')
|
||||
return {"timeout": timeout, "chatlist": chatlist}
|
||||
|
||||
def _parse_message(self, message):
|
||||
if message is None:
|
||||
return ''
|
||||
if message.get('simpleText'):
|
||||
@@ -51,11 +53,9 @@ class SimpleDisplayProcessor(ChatProcessor):
|
||||
tmp = ''
|
||||
for run in runs:
|
||||
if run.get('emoji'):
|
||||
tmp+=(run['emoji']['shortcuts'][0])
|
||||
tmp += (run['emoji']['shortcuts'][0])
|
||||
elif run.get('text'):
|
||||
tmp+=(run['text'])
|
||||
tmp += (run['text'])
|
||||
return tmp
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ Calculate speed of chat.
|
||||
"""
|
||||
import time
|
||||
from .. chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class RingQueue:
|
||||
"""
|
||||
リング型キュー
|
||||
|
||||
|
||||
Attributes
|
||||
----------
|
||||
items : list
|
||||
@@ -21,10 +23,10 @@ class RingQueue:
|
||||
キュー内に余裕があるか。キュー内のアイテム個数が、キューの最大個数未満であればTrue。
|
||||
"""
|
||||
|
||||
def __init__(self, capacity):
|
||||
def __init__(self, capacity):
|
||||
"""
|
||||
コンストラクタ
|
||||
|
||||
|
||||
Parameter
|
||||
----------
|
||||
capacity:このキューに格納するアイテムの最大個数。
|
||||
@@ -50,17 +52,17 @@ class RingQueue:
|
||||
"""
|
||||
if self.mergin:
|
||||
self.items.append(item)
|
||||
self.last_pos = len(self.items)-1
|
||||
if self.last_pos == self.capacity-1:
|
||||
self.last_pos = len(self.items) - 1
|
||||
if self.last_pos == self.capacity - 1:
|
||||
self.mergin = False
|
||||
return
|
||||
self.last_pos += 1
|
||||
if self.last_pos > self.capacity-1:
|
||||
if self.last_pos > self.capacity - 1:
|
||||
self.last_pos = 0
|
||||
self.items[self.last_pos] = item
|
||||
|
||||
|
||||
self.first_pos += 1
|
||||
if self.first_pos > self.capacity-1:
|
||||
if self.first_pos > self.capacity - 1:
|
||||
self.first_pos = 0
|
||||
|
||||
def get(self):
|
||||
@@ -76,11 +78,12 @@ class RingQueue:
|
||||
|
||||
def item_count(self):
|
||||
return len(self.items)
|
||||
|
||||
|
||||
|
||||
class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
"""
|
||||
チャットの勢いを計算する。
|
||||
|
||||
|
||||
一定期間のチャットデータのうち、最初のチャットの投稿時刻と
|
||||
最後のチャットの投稿時刻の差を、チャット数で割り返し
|
||||
1分あたりの速度に換算する。
|
||||
@@ -91,7 +94,7 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
RingQueueに格納するチャット勢い算出用データの最大数
|
||||
"""
|
||||
|
||||
def __init__(self, capacity = 10):
|
||||
def __init__(self, capacity=10):
|
||||
super().__init__(capacity)
|
||||
self.speed = 0
|
||||
|
||||
@@ -105,7 +108,6 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
self._put_chatdata(chatdata)
|
||||
self.speed = self._calc_speed()
|
||||
return self.speed
|
||||
|
||||
|
||||
def _calc_speed(self):
|
||||
"""
|
||||
@@ -116,14 +118,13 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
---------------------------
|
||||
チャット速度(1分間で換算したチャット数)
|
||||
"""
|
||||
try:
|
||||
#キュー内の総チャット数
|
||||
try:
|
||||
# キュー内の総チャット数
|
||||
total = sum(item['chat_count'] for item in self.items)
|
||||
#キュー内の最初と最後のチャットの時間差
|
||||
duration = (self.items[self.last_pos]['endtime']
|
||||
- self.items[self.first_pos]['starttime'])
|
||||
# キュー内の最初と最後のチャットの時間差
|
||||
duration = (self.items[self.last_pos]['endtime'] - self.items[self.first_pos]['starttime'])
|
||||
if duration != 0:
|
||||
return int(total*60/duration)
|
||||
return int(total * 60 / duration)
|
||||
return 0
|
||||
except IndexError:
|
||||
return 0
|
||||
@@ -143,61 +144,60 @@ class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
'''
|
||||
チャットデータがない場合に空のデータをキューに投入する。
|
||||
'''
|
||||
timestamp_now = int(time.time())
|
||||
timestamp_now = int(time.time())
|
||||
self.put({
|
||||
'chat_count':0,
|
||||
'starttime':int(timestamp_now),
|
||||
'endtime':int(timestamp_now)
|
||||
'chat_count': 0,
|
||||
'starttime': int(timestamp_now),
|
||||
'endtime': int(timestamp_now)
|
||||
})
|
||||
|
||||
def _get_timestamp(action :dict):
|
||||
def _get_timestamp(action: dict):
|
||||
"""
|
||||
チャットデータから時刻データを取り出す。
|
||||
"""
|
||||
try:
|
||||
item = action['addChatItemAction']['item']
|
||||
timestamp = int(item[list(item.keys())[0]]['timestampUsec'])
|
||||
except (KeyError,TypeError):
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
return timestamp
|
||||
|
||||
if actions is None or len(actions)==0:
|
||||
if actions is None or len(actions) == 0:
|
||||
_put_emptydata()
|
||||
return
|
||||
|
||||
#actions内の時刻データを持つチャットデータの数
|
||||
counter=0
|
||||
#actions内の最初のチャットデータの時刻
|
||||
starttime= None
|
||||
#actions内の最後のチャットデータの時刻
|
||||
endtime=None
|
||||
|
||||
return
|
||||
|
||||
# actions内の時刻データを持つチャットデータの数
|
||||
counter = 0
|
||||
# actions内の最初のチャットデータの時刻
|
||||
starttime = None
|
||||
# actions内の最後のチャットデータの時刻
|
||||
endtime = None
|
||||
|
||||
for action in actions:
|
||||
#チャットデータからtimestampUsecを読み取る
|
||||
# チャットデータからtimestampUsecを読み取る
|
||||
gettime = _get_timestamp(action)
|
||||
|
||||
#時刻のないデータだった場合は次の行のデータで読み取り試行
|
||||
|
||||
# 時刻のないデータだった場合は次の行のデータで読み取り試行
|
||||
if gettime is None:
|
||||
continue
|
||||
|
||||
#最初に有効な時刻を持つデータのtimestampをstarttimeに設定
|
||||
|
||||
# 最初に有効な時刻を持つデータのtimestampをstarttimeに設定
|
||||
if starttime is None:
|
||||
starttime = gettime
|
||||
|
||||
#最後のtimestampを設定(途中で時刻のないデータの場合もあるので上書きしていく)
|
||||
|
||||
# 最後のtimestampを設定(途中で時刻のないデータの場合もあるので上書きしていく)
|
||||
endtime = gettime
|
||||
|
||||
#チャットの数をインクリメント
|
||||
|
||||
# チャットの数をインクリメント
|
||||
counter += 1
|
||||
|
||||
#チャット速度用のデータをRingQueueに送る
|
||||
# チャット速度用のデータをRingQueueに送る
|
||||
if starttime is None or endtime is None:
|
||||
_put_emptydata()
|
||||
return
|
||||
|
||||
self.put({
|
||||
'chat_count':counter,
|
||||
'starttime':int(starttime/1000000),
|
||||
'endtime':int(endtime/1000000)
|
||||
})
|
||||
return
|
||||
|
||||
self.put({
|
||||
'chat_count': counter,
|
||||
'starttime': int(starttime / 1000000),
|
||||
'endtime': int(endtime / 1000000)
|
||||
})
|
||||
|
||||
@@ -15,10 +15,12 @@ items_sticker = [
|
||||
'liveChatPaidStickerRenderer'
|
||||
]
|
||||
|
||||
|
||||
class SuperchatCalculator(ChatProcessor):
|
||||
"""
|
||||
Calculate the amount of SuperChat by currency.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = {}
|
||||
|
||||
@@ -34,22 +36,24 @@ class SuperchatCalculator(ChatProcessor):
|
||||
return self.results
|
||||
for component in chat_components:
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata is None: continue
|
||||
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
|
||||
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
|
||||
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(',',''))
|
||||
amount = float(m.group(2).replace(',', ''))
|
||||
else:
|
||||
symbol = ""
|
||||
amount = 0.0
|
||||
@@ -69,6 +73,3 @@ class SuperchatCalculator(ChatProcessor):
|
||||
continue
|
||||
return None
|
||||
return dict_body
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ 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']
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
fmt_headers = ['datetime', 'elapsed', 'authorName', 'message',
|
||||
'superchatAmount', 'authorType', 'authorChannel']
|
||||
|
||||
|
||||
class TSVArchiver(ChatProcessor):
|
||||
'''
|
||||
@@ -16,7 +17,7 @@ class TSVArchiver(ChatProcessor):
|
||||
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:
|
||||
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()
|
||||
@@ -28,30 +29,30 @@ class TSVArchiver(ChatProcessor):
|
||||
newpath = filepath
|
||||
counter = 0
|
||||
while os.path.exists(newpath):
|
||||
match = re.search(PATTERN,body)
|
||||
match = re.search(PATTERN, body)
|
||||
if match:
|
||||
counter=int(match[2])+1
|
||||
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)
|
||||
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||
return newpath
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
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:
|
||||
if chat_components is None or len(chat_components) == 0:
|
||||
return
|
||||
|
||||
with open(self.save_path, mode='a', encoding = 'utf-8') as f:
|
||||
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:
|
||||
@@ -64,7 +65,3 @@ class TSVArchiver(ChatProcessor):
|
||||
c.author.type,
|
||||
c.author.channelId
|
||||
])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import socket
|
||||
from . import parser
|
||||
from . block import Block
|
||||
from . worker import ExtractWorker
|
||||
from . patch import Patch
|
||||
from ... import config
|
||||
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):
|
||||
# Set to avoid duplicate parameters
|
||||
param_set = set()
|
||||
|
||||
|
||||
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.
|
||||
and returns the beginning of each piece.
|
||||
The `count` is adjusted so that the length of each piece
|
||||
is no smaller than `min_interval`.
|
||||
|
||||
@@ -28,97 +33,118 @@ def _split(start, end, count, min_interval_sec = 120):
|
||||
--------
|
||||
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)):
|
||||
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):
|
||||
if not isinstance(count, int):
|
||||
raise ValueError("count must be int")
|
||||
if start>end:
|
||||
if start > end:
|
||||
raise ValueError("end must be equal to or greater than start.")
|
||||
if count<1:
|
||||
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 (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) ])))
|
||||
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
|
||||
param_set.clear()
|
||||
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)]
|
||||
async def _get_blocks(video_id, duration, div, callback):
|
||||
async with httpx.AsyncClient(http2=True) 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)
|
||||
continuation = arcparam.getparam(video_id, seektime=seektime)
|
||||
url = f"{REPLAY_URL}{quote(continuation)}&pbj=1"
|
||||
err = None
|
||||
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))
|
||||
try:
|
||||
if continuation in param_set:
|
||||
next_continuation, actions = None, []
|
||||
break
|
||||
param_set.add(continuation)
|
||||
resp = await session.get(url, headers=headers, timeout=10)
|
||||
next_continuation, actions = parser.parse(resp.json())
|
||||
break
|
||||
except JSONDecodeError:
|
||||
await asyncio.sleep(3)
|
||||
except httpx.HTTPError as e:
|
||||
err = e
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
cancel()
|
||||
raise UnknownConnectionError("Abort: Unknown connection error.")
|
||||
raise UnknownConnectionError("Abort:" + str(err))
|
||||
|
||||
if actions:
|
||||
first = parser.get_offset(actions[0])
|
||||
last = parser.get_offset(actions[-1])
|
||||
if callback:
|
||||
callback(actions,last-first)
|
||||
callback(actions, last - first)
|
||||
return Block(
|
||||
continuation = next_continuation,
|
||||
chat_data = actions,
|
||||
first = first,
|
||||
last = last
|
||||
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
|
||||
fetch=_fetch, block=block,
|
||||
blocks=blocks, video_id=video_id
|
||||
)
|
||||
for block in blocks
|
||||
]
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with httpx.AsyncClient() as session:
|
||||
tasks = [worker.run(session) for worker in workers]
|
||||
return await asyncio.gather(*tasks)
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def _fetch(continuation,session) -> Patch:
|
||||
async def _fetch(continuation, session) -> Patch:
|
||||
url = f"{REPLAY_URL}{quote(continuation)}&pbj=1"
|
||||
err = None
|
||||
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))
|
||||
if continuation in param_set:
|
||||
continuation, actions = None, []
|
||||
break
|
||||
param_set.add(continuation)
|
||||
resp = await session.get(url, headers=config.headers)
|
||||
continuation, actions = parser.parse(resp.json())
|
||||
break
|
||||
except JSONDecodeError:
|
||||
await asyncio.sleep(3)
|
||||
except httpx.HTTPError as e:
|
||||
err = e
|
||||
await asyncio.sleep(3)
|
||||
except socket.error as error:
|
||||
print("socket error", error.errno)
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
cancel()
|
||||
raise UnknownConnectionError("Abort: Unknown connection error.")
|
||||
raise UnknownConnectionError("Abort:" + str(err))
|
||||
|
||||
if actions:
|
||||
last = parser.get_offset(actions[-1])
|
||||
@@ -126,29 +152,25 @@ def fetch_patch(callback, blocks, video_id):
|
||||
if callback:
|
||||
callback(actions, last - first)
|
||||
return Patch(actions, continuation, first, last)
|
||||
return Patch(continuation = continuation)
|
||||
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()]
|
||||
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())
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from . import parser
|
||||
class Block:
|
||||
"""Block object represents something like a box
|
||||
"""Block object represents something like a box
|
||||
to join chunk of chatdata.
|
||||
|
||||
Parameter:
|
||||
---------
|
||||
first : int :
|
||||
videoOffsetTimeMs of the first chat_data
|
||||
videoOffsetTimeMs of the first chat_data
|
||||
(chat_data[0])
|
||||
|
||||
|
||||
last : int :
|
||||
videoOffsetTimeMs of the last chat_data.
|
||||
(chat_data[-1])
|
||||
@@ -23,15 +22,15 @@ class Block:
|
||||
continuation : str :
|
||||
continuation param of last chat data.
|
||||
|
||||
chat_data : list
|
||||
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.
|
||||
|
||||
@@ -39,13 +38,13 @@ class Block:
|
||||
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):
|
||||
__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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from . import parser
|
||||
|
||||
|
||||
def check_duplicate(chatdata):
|
||||
max_range = len(chatdata)-1
|
||||
max_range = len(chatdata) - 1
|
||||
tbl_offset = [None] * max_range
|
||||
tbl_id = [None] * max_range
|
||||
tbl_type = [None] * max_range
|
||||
@@ -9,33 +10,31 @@ def check_duplicate(chatdata):
|
||||
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_id[i] = parser.get_id(chatdata[i])
|
||||
tbl_type[i] = parser.get_type(chatdata[i])
|
||||
|
||||
def is_duplicate(i, j):
|
||||
return (
|
||||
return (
|
||||
tbl_offset[i] == tbl_offset[j]
|
||||
and
|
||||
tbl_id[i] == tbl_id[j]
|
||||
and
|
||||
tbl_type[i] == tbl_type[j]
|
||||
and tbl_id[i] == tbl_id[j]
|
||||
and tbl_type[i] == tbl_type[j]
|
||||
)
|
||||
print("creating table...")
|
||||
create_table(chatdata,max_range)
|
||||
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)]
|
||||
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):
|
||||
@@ -47,91 +46,92 @@ def check_duplicate_offset(chatdata):
|
||||
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_id[i] = parser.get_id(chatdata[i])
|
||||
tbl_type[i] = parser.get_type(chatdata[i])
|
||||
|
||||
def is_duplicate(i, j):
|
||||
return (
|
||||
return (
|
||||
tbl_offset[i] == tbl_offset[j]
|
||||
and
|
||||
tbl_id[i] == tbl_id[j]
|
||||
and tbl_id[i] == tbl_id[j]
|
||||
)
|
||||
|
||||
print("creating table...")
|
||||
create_table(chatdata,max_range)
|
||||
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)]
|
||||
"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) == 1 : return 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:
|
||||
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])
|
||||
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])
|
||||
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
|
||||
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 = [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) == 1 : return 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:
|
||||
elif len(blocks[index - 1].chat_data) == 0:
|
||||
return False
|
||||
id_0 = parser.get_id(blocks[index-1].chat_data[-1])
|
||||
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_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
|
||||
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) ]
|
||||
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
|
||||
Align the last offset of each block to the first offset
|
||||
of next block (equals `end` offset of each block).
|
||||
"""
|
||||
if len(blocks) == 1 : return blocks
|
||||
if len(blocks) == 0 or len(blocks) == 1:
|
||||
return blocks
|
||||
|
||||
for block in blocks:
|
||||
if block.is_last:
|
||||
break
|
||||
if len(block.chat_data)==0:
|
||||
if len(block.chat_data) == 0:
|
||||
continue
|
||||
block_end = block.end
|
||||
if block.last >= block_end:
|
||||
@@ -140,14 +140,14 @@ def remove_overlap(blocks):
|
||||
break
|
||||
block.chat_data.pop()
|
||||
block.last = parser.get_offset(line)
|
||||
block.remaining=0
|
||||
block.done=True
|
||||
block.remaining = 0
|
||||
block.done = True
|
||||
block.continuation = None
|
||||
return blocks
|
||||
|
||||
|
||||
|
||||
|
||||
def _dump(blocks):
|
||||
print(f"---------- first last end---")
|
||||
for i,block in enumerate(blocks):
|
||||
print(f"block[{i:3}] {block.first:>10} {block.last:>10} {block.end:>10}")
|
||||
print("---------- first last end---")
|
||||
for i, block in enumerate(blocks):
|
||||
print(
|
||||
f"block[{i:3}] {block.first:>10} {block.last:>10} {block.end:>10}")
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
from typing import Generator
|
||||
from . import asyncdl
|
||||
from . import duplcheck
|
||||
from . import parser
|
||||
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
|
||||
headers = config.headers
|
||||
|
||||
|
||||
class Extractor:
|
||||
def __init__(self, video_id, div = 1, callback = None, processor = None):
|
||||
if not isinstance(div ,int) or div < 1:
|
||||
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 = video_id
|
||||
self.video_id = extract_video_id(video_id)
|
||||
self.div = div
|
||||
self.callback = callback
|
||||
self.processor = processor
|
||||
@@ -33,17 +35,18 @@ class Extractor:
|
||||
blocks = asyncdl.ready_blocks(
|
||||
self.video_id, self.duration, self.div, self.callback)
|
||||
self.blocks = [block for block in blocks if block]
|
||||
return self
|
||||
return self
|
||||
|
||||
def _remove_duplicate_head(self):
|
||||
self.blocks = duplcheck.remove_duplicate_head(self.blocks)
|
||||
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*1000
|
||||
self.blocks[-1].is_last =True
|
||||
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):
|
||||
@@ -58,11 +61,10 @@ class Extractor:
|
||||
self.blocks = duplcheck.remove_duplicate_tail(self.blocks)
|
||||
return self
|
||||
|
||||
def _combine(self):
|
||||
ret = []
|
||||
def _get_chatdata(self) -> Generator:
|
||||
for block in self.blocks:
|
||||
ret.extend(block.chat_data)
|
||||
return ret
|
||||
for chatdata in block.chat_data:
|
||||
yield chatdata
|
||||
|
||||
def _execute_extract_operations(self):
|
||||
return (
|
||||
@@ -72,20 +74,23 @@ class Extractor:
|
||||
._remove_overlap()
|
||||
._download_blocks()
|
||||
._remove_duplicate_tail()
|
||||
._combine()
|
||||
._get_chatdata()
|
||||
)
|
||||
|
||||
def extract(self):
|
||||
if self.duration == 0:
|
||||
print("video is not archived.")
|
||||
print("\nCannot extract chat data:\n The specified video has not yet been archived.")
|
||||
return []
|
||||
data = self._execute_extract_operations()
|
||||
if self.processor is None:
|
||||
return data
|
||||
return self.processor.process(
|
||||
[{'video_id':None,'timeout':1,'chatdata' : (action
|
||||
["replayChatItemAction"]["actions"][0] for action in data)}]
|
||||
)
|
||||
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()
|
||||
asyncdl.cancel()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import json
|
||||
from ... import config
|
||||
from ... exceptions import (
|
||||
ResponseContextError,
|
||||
NoContentsException,
|
||||
NoContinuationsException )
|
||||
from ... import exceptions
|
||||
|
||||
logger = config.logger(__name__)
|
||||
|
||||
|
||||
def parse(jsn):
|
||||
"""
|
||||
Parse replay chat data.
|
||||
@@ -20,18 +17,18 @@ def parse(jsn):
|
||||
actions : list
|
||||
|
||||
"""
|
||||
if jsn is None:
|
||||
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')
|
||||
raise exceptions.ResponseContextError(
|
||||
'video_id is invalid or private/deleted.')
|
||||
contents = jsn['response'].get('continuationContents')
|
||||
if contents is None:
|
||||
raise NoContentsException('No chat data.')
|
||||
raise exceptions.NoContents('No chat data.')
|
||||
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont is None:
|
||||
raise NoContinuationsException('No Continuation')
|
||||
raise exceptions.NoContinuation('No Continuation')
|
||||
metadata = cont.get('liveChatReplayContinuationData')
|
||||
if metadata:
|
||||
continuation = metadata.get("continuation")
|
||||
@@ -43,12 +40,16 @@ def parse(jsn):
|
||||
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')
|
||||
a = list(item['replayChatItemAction']["actions"][0].values())[0].get('item')
|
||||
if a:
|
||||
return list(a.values())[0].get('id')
|
||||
return None
|
||||
|
||||
|
||||
def get_type(item):
|
||||
return list((list(item['replayChatItemAction']["actions"][0].values()
|
||||
)[0])['item'].keys())[0]
|
||||
|
||||
|
||||
a = list(item['replayChatItemAction']["actions"][0].values())[0].get('item')
|
||||
if a:
|
||||
return list(a.keys())[0]
|
||||
return None
|
||||
|
||||
@@ -2,17 +2,19 @@ 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
|
||||
chats: list = []
|
||||
continuation: str = None
|
||||
first: int = None
|
||||
last: int = None
|
||||
|
||||
def fill(block:Block, patch:Patch):
|
||||
|
||||
def fill(block: Block, patch: Patch):
|
||||
block_end = block.end
|
||||
if patch.last < block_end or block.is_last:
|
||||
set_patch(block, patch)
|
||||
@@ -23,32 +25,31 @@ def fill(block:Block, patch:Patch):
|
||||
break
|
||||
patch.chats.pop()
|
||||
set_patch(block, patch._replace(
|
||||
continuation = None,
|
||||
last = line_offset
|
||||
)
|
||||
continuation=None,
|
||||
last=line_offset
|
||||
)
|
||||
block.remaining=0
|
||||
block.done=True
|
||||
)
|
||||
block.remaining = 0
|
||||
block.done = True
|
||||
|
||||
|
||||
def split(parent_block:Block, child_block:Block, patch:Patch):
|
||||
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
|
||||
''' Leave child_block.during_split == True
|
||||
to exclude from during_split sequence. '''
|
||||
return
|
||||
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):
|
||||
|
||||
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
|
||||
|
||||
block.remaining = block.end - block.last
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from . import parser
|
||||
from . block import Block
|
||||
from . patch import Patch, fill, split
|
||||
from . patch import fill, split
|
||||
from ... paramgen import arcparam
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -17,18 +17,18 @@ class ExtractWorker:
|
||||
|
||||
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
|
||||
the block from which current block is splitted
|
||||
"""
|
||||
__slots__ = ['block', 'fetch', 'blocks', 'video_id', 'parent_block']
|
||||
|
||||
def __init__(self, fetch, block, blocks, video_id ):
|
||||
def __init__(self, fetch, block, blocks, video_id):
|
||||
self.block = block
|
||||
self.fetch = fetch
|
||||
self.blocks = blocks
|
||||
@@ -47,34 +47,36 @@ class ExtractWorker:
|
||||
if self.parent_block:
|
||||
split(self.parent_block, self.block, patch)
|
||||
self.parent_block = None
|
||||
else:
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
worker.blocks.insert(index + 1, new_block)
|
||||
return new_block
|
||||
|
||||
def _get_undone_block(blocks) -> (int, Block):
|
||||
|
||||
def _get_undone_block(blocks) -> Tuple[int, Block]:
|
||||
min_interval_ms = 120000
|
||||
max_remaining = 0
|
||||
undone_block = None
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
|
||||
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())
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import json
|
||||
from ... import config
|
||||
from ... exceptions import (
|
||||
ResponseContextError,
|
||||
NoContentsException,
|
||||
NoContinuationsException )
|
||||
|
||||
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 NoContentsException('No chat data.')
|
||||
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont is None:
|
||||
raise NoContinuationsException('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]
|
||||
import re
|
||||
_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
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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()
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import json
|
||||
import httpx
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import time
|
||||
from .. import config
|
||||
from .. import util
|
||||
from ..exceptions import InvalidVideoIdException
|
||||
from ..exceptions import InvalidVideoIdException, PatternUnmatchError, UnknownConnectionError
|
||||
from ..util.extract_video_id import extract_video_id
|
||||
|
||||
|
||||
headers = config.headers
|
||||
pattern = re.compile(r"['\"]PLAYER_CONFIG['\"]:\s*({.*})")
|
||||
pattern2 = re.compile(r"yt\.setConfig\((\{[\s\S]*?\})\);")
|
||||
|
||||
pattern = re.compile(r"yt\.setConfig\({'PLAYER_CONFIG': ({.*})}\);")
|
||||
|
||||
item_channel_id =[
|
||||
item_channel_id = [
|
||||
"videoDetails",
|
||||
"embeddedPlayerOverlayVideoDetailsRenderer",
|
||||
"channelThumbnailEndpoint",
|
||||
@@ -29,7 +31,11 @@ item_response = [
|
||||
"embedded_player_response"
|
||||
]
|
||||
|
||||
item_author_image =[
|
||||
item_response2 = [
|
||||
"PLAYER_VARS",
|
||||
"embedded_player_response"
|
||||
]
|
||||
item_author_image = [
|
||||
"videoDetails",
|
||||
"embeddedPlayerOverlayVideoDetailsRenderer",
|
||||
"channelThumbnail",
|
||||
@@ -63,6 +69,7 @@ item_moving_thumbnail = [
|
||||
"url"
|
||||
]
|
||||
|
||||
|
||||
class VideoInfo:
|
||||
'''
|
||||
VideoInfo object retrieves YouTube video information.
|
||||
@@ -76,29 +83,70 @@ class VideoInfo:
|
||||
InvalidVideoIdException :
|
||||
Occurs when video_id does not exist on YouTube.
|
||||
'''
|
||||
|
||||
def __init__(self, video_id):
|
||||
self.video_id = video_id
|
||||
text = self._get_page_text(video_id)
|
||||
self._parse(text)
|
||||
self.video_id = extract_video_id(video_id)
|
||||
self.client = httpx.Client(http2=True)
|
||||
self.new_pattern_text = False
|
||||
err = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
text = self._get_page_text(self.video_id)
|
||||
self._parse(text)
|
||||
break
|
||||
except (InvalidVideoIdException, UnknownConnectionError) as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
err = e
|
||||
time.sleep(2)
|
||||
pass
|
||||
else:
|
||||
raise err
|
||||
|
||||
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()
|
||||
err = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
resp = self.client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
break
|
||||
except httpx.HTTPError as e:
|
||||
err = e
|
||||
time.sleep(3)
|
||||
else:
|
||||
raise UnknownConnectionError(str(err))
|
||||
|
||||
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 result is None:
|
||||
result = re.search(pattern2, text)
|
||||
if result is None:
|
||||
raise PatternUnmatchError(doc=text)
|
||||
else:
|
||||
self.new_pattern_text = True
|
||||
decoder = json.JSONDecoder()
|
||||
if self.new_pattern_text:
|
||||
res = decoder.raw_decode(result.group(1))[0]
|
||||
else:
|
||||
res = decoder.raw_decode(result.group(1)[:-1])[0]
|
||||
if self.new_pattern_text:
|
||||
response = self._get_item(res, item_response2)
|
||||
else:
|
||||
response = self._get_item(res, item_response)
|
||||
if response is None:
|
||||
self._check_video_is_private(res.get("args"))
|
||||
if self.new_pattern_text:
|
||||
self._check_video_is_private(res.get("PLAYER_VARS"))
|
||||
else:
|
||||
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):
|
||||
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.")
|
||||
@@ -130,8 +178,8 @@ class VideoInfo:
|
||||
|
||||
def get_title(self):
|
||||
if self._renderer.get("title"):
|
||||
return [''.join(run["text"])
|
||||
for run in self._renderer["title"]["runs"]][0]
|
||||
return [''.join(run["text"])
|
||||
for run in self._renderer["title"]["runs"]][0]
|
||||
return None
|
||||
|
||||
def get_channel_id(self):
|
||||
@@ -141,13 +189,13 @@ class VideoInfo:
|
||||
return None
|
||||
|
||||
def get_author_image(self):
|
||||
return self._get_item(self._renderer, item_author_image)
|
||||
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,41 @@
|
||||
import requests,json,datetime
|
||||
import datetime
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from .. import config
|
||||
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
|
||||
|
||||
def extract(url):
|
||||
_session = requests.Session()
|
||||
_session = httpx.Client(http2=True)
|
||||
html = _session.get(url, headers=config.headers)
|
||||
with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
||||
)+'test.json',mode ='w',encoding='utf-8') as f:
|
||||
json.dump(html.json(),f,ensure_ascii=False)
|
||||
) + 'test.json', mode='w', encoding='utf-8') as f:
|
||||
json.dump(html.json(), f, ensure_ascii=False)
|
||||
|
||||
|
||||
def save(data,filename,extention):
|
||||
with open(filename+"_"+(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
||||
)+extention,mode ='w',encoding='utf-8') as f:
|
||||
def save(data, filename, extention) -> str:
|
||||
save_filename = filename + "_" + (datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')) + extention
|
||||
with open(save_filename ,mode='w', encoding='utf-8') as f:
|
||||
f.writelines(data)
|
||||
return save_filename
|
||||
|
||||
|
||||
def checkpath(filepath):
|
||||
splitter = os.path.splitext(os.path.basename(filepath))
|
||||
body = splitter[0]
|
||||
extention = splitter[1]
|
||||
newpath = filepath
|
||||
counter = 1
|
||||
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
|
||||
|
||||
28
pytchat/util/extract_video_id.py
Normal file
28
pytchat/util/extract_video_id.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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 '[' in url_or_id:
|
||||
url_or_id = url_or_id.replace('[', '').replace(']', '')
|
||||
|
||||
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(f"Invalid video id: {url_or_id}")
|
||||
try:
|
||||
ret = match.group(4)
|
||||
except IndexError:
|
||||
raise InvalidVideoIdException(f"Invalid video id: {url_or_id}")
|
||||
|
||||
if ret is None or len(ret) != YT_VIDEO_ID_LENGTH:
|
||||
raise InvalidVideoIdException(f"Invalid video id: {url_or_id}")
|
||||
return ret
|
||||
@@ -1,4 +1,4 @@
|
||||
aiohttp
|
||||
httpx[http2]==0.16.1
|
||||
protobuf==3.14.0
|
||||
pytz
|
||||
requests
|
||||
urllib3
|
||||
@@ -1,5 +1,2 @@
|
||||
aioresponses
|
||||
mock
|
||||
mocker
|
||||
pytest
|
||||
pytest-mock
|
||||
pytest-mock==3.3.1
|
||||
pytest-httpx==0.10.0
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import pytest
|
||||
from pytchat.parser.live import Parser
|
||||
import json
|
||||
import httpx
|
||||
import pytchat.config as config
|
||||
import requests, json
|
||||
from pytchat.paramgen import arcparam
|
||||
from pytchat.parser.live import Parser
|
||||
|
||||
|
||||
def test_arcparam_0(mocker):
|
||||
param = arcparam.getparam("01234567890",-1)
|
||||
assert param == "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoADAAOABAAEgEUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAHIECAEQAHgA"
|
||||
param = arcparam.getparam("01234567890", -1)
|
||||
assert param == "op2w0wQmGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09SARgAXICCAE%3D"
|
||||
|
||||
|
||||
def test_arcparam_1(mocker):
|
||||
param = arcparam.getparam("01234567890", seektime = 100000)
|
||||
assert param == "op2w0wR3GjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QogNDbw_QCMAA4AEAASANSHAgAEAAYACAAKg5zdGF0aWNjaGVja3N1bUAAWANgAWgAcgQIARAAeAA%3D"
|
||||
param = arcparam.getparam("01234567890", seektime=100000)
|
||||
assert param == "op2w0wQtGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09KIDQ28P0AkgDYAFyAggB"
|
||||
|
||||
|
||||
def test_arcparam_2(mocker):
|
||||
param = arcparam.getparam("SsjCnHOk-Sk")
|
||||
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)
|
||||
param = arcparam.getparam("SsjCnHOk-Sk", seektime=100)
|
||||
url = f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?continuation={param}&pbj=1"
|
||||
resp = httpx.Client(http2=True).get(url, headers=config.headers)
|
||||
jsn = json.loads(resp.text)
|
||||
parser = Parser(is_replay=True)
|
||||
contents= parser.get_contents(jsn)
|
||||
_ , chatdata = parser.parse(contents)
|
||||
contents = parser.get_contents(jsn)
|
||||
_, chatdata = parser.parse(contents)
|
||||
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"]
|
||||
assert test_id == "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w"
|
||||
|
||||
|
||||
def test_arcparam_3(mocker):
|
||||
param = arcparam.getparam("01234567890")
|
||||
assert param == "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoATAAOABAAEgDUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAHIECAEQAHgA"
|
||||
assert param == "op2w0wQmGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09SARgAXICCAE%3D"
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import pytest
|
||||
from pytchat.tool.mining import parser
|
||||
import pytchat.config as config
|
||||
import requests, 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"
|
||||
@@ -1,19 +1,10 @@
|
||||
import json
|
||||
import pytest
|
||||
import asyncio,aiohttp
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.compatible.processor import CompatibleProcessor
|
||||
from pytchat.exceptions import (
|
||||
NoLivechatRendererException,NoYtinitialdataException,
|
||||
ResponseContextError, NoContentsException)
|
||||
|
||||
from pytchat.processors.compatible.renderer.textmessage import LiveChatTextMessageRenderer
|
||||
from pytchat.processors.compatible.renderer.paidmessage import LiveChatPaidMessageRenderer
|
||||
from pytchat.processors.compatible.renderer.paidsticker import LiveChatPaidStickerRenderer
|
||||
from pytchat.processors.compatible.renderer.legacypaid import LiveChatLegacyPaidMessageRenderer
|
||||
|
||||
parser = Parser(is_replay=False)
|
||||
|
||||
|
||||
def test_textmessage(mocker):
|
||||
'''api互換processorのテスト:通常テキストメッセージ'''
|
||||
processor = CompatibleProcessor()
|
||||
@@ -22,34 +13,37 @@ def test_textmessage(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 7,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 7,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
|
||||
assert ret["kind"]== "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"]==data["timeout"]*1000
|
||||
assert ret["kind"] == "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"] == data["timeout"] * 1000
|
||||
assert ret.keys() == {
|
||||
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items"
|
||||
"kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
|
||||
}
|
||||
assert ret["pageInfo"].keys() == {
|
||||
"totalResults", "resultsPerPage"
|
||||
"totalResults", "resultsPerPage"
|
||||
}
|
||||
assert ret["items"][0].keys() == {
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
}
|
||||
assert ret["items"][0]["snippet"].keys() == {
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage', 'textMessageDetails'
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage',
|
||||
'textMessageDetails'
|
||||
}
|
||||
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() == {
|
||||
'messageText'
|
||||
}
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"]=="textMessageEvent"
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"] == "textMessageEvent"
|
||||
|
||||
|
||||
def test_newsponcer(mocker):
|
||||
'''api互換processorのテスト:メンバ新規登録'''
|
||||
@@ -59,32 +53,70 @@ def test_newsponcer(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 7,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 7,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
|
||||
assert ret["kind"]== "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"]==data["timeout"]*1000
|
||||
assert ret["kind"] == "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"] == data["timeout"] * 1000
|
||||
assert ret.keys() == {
|
||||
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items"
|
||||
"kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
|
||||
}
|
||||
assert ret["pageInfo"].keys() == {
|
||||
"totalResults", "resultsPerPage"
|
||||
"totalResults", "resultsPerPage"
|
||||
}
|
||||
assert ret["items"][0].keys() == {
|
||||
"kind", "etag", "id", "snippet","authorDetails"
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
}
|
||||
assert ret["items"][0]["snippet"].keys() == {
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage'
|
||||
|
||||
}
|
||||
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 ret["items"][0]["snippet"]["type"]=="newSponsorEvent"
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"] == "newSponsorEvent"
|
||||
|
||||
|
||||
def test_newsponcer_rev(mocker):
|
||||
'''api互換processorのテスト:メンバ新規登録'''
|
||||
processor = CompatibleProcessor()
|
||||
|
||||
_json = _open_file("tests/testdata/compatible/newSponsor_rev.json")
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id": "",
|
||||
"timeout": 7,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
|
||||
assert ret["kind"] == "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"] == data["timeout"] * 1000
|
||||
assert ret.keys() == {
|
||||
"kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
|
||||
}
|
||||
assert ret["pageInfo"].keys() == {
|
||||
"totalResults", "resultsPerPage"
|
||||
}
|
||||
assert ret["items"][0].keys() == {
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
}
|
||||
assert ret["items"][0]["snippet"].keys() == {
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage'
|
||||
|
||||
}
|
||||
assert ret["items"][0]["authorDetails"].keys() == {
|
||||
'channelId', 'channelUrl', 'displayName', 'profileImageUrl', 'isVerified', 'isChatOwner', 'isChatSponsor',
|
||||
'isChatModerator'
|
||||
}
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"] == "newSponsorEvent"
|
||||
|
||||
|
||||
def test_superchat(mocker):
|
||||
@@ -95,34 +127,37 @@ def test_superchat(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 7,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 7,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
|
||||
assert ret["kind"]== "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"]==data["timeout"]*1000
|
||||
assert ret["kind"] == "youtube#liveChatMessageListResponse"
|
||||
assert ret["pollingIntervalMillis"] == data["timeout"] * 1000
|
||||
assert ret.keys() == {
|
||||
"kind", "etag", "pageInfo", "nextPageToken","pollingIntervalMillis","items"
|
||||
"kind", "etag", "pageInfo", "nextPageToken", "pollingIntervalMillis", "items"
|
||||
}
|
||||
assert ret["pageInfo"].keys() == {
|
||||
"totalResults", "resultsPerPage"
|
||||
"totalResults", "resultsPerPage"
|
||||
}
|
||||
assert ret["items"][0].keys() == {
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
"kind", "etag", "id", "snippet", "authorDetails"
|
||||
}
|
||||
assert ret["items"][0]["snippet"].keys() == {
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage', 'superChatDetails'
|
||||
'type', 'liveChatId', 'authorChannelId', 'publishedAt', 'hasDisplayContent', 'displayMessage',
|
||||
'superChatDetails'
|
||||
}
|
||||
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() == {
|
||||
'amountMicros', 'currency', 'amountDisplayString', 'tier', 'backgroundColor'
|
||||
}
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"]=="superChatEvent"
|
||||
assert "LCC." in ret["items"][0]["id"]
|
||||
assert ret["items"][0]["snippet"]["type"] == "superChatEvent"
|
||||
|
||||
|
||||
def test_unregistered_currency(mocker):
|
||||
processor = CompatibleProcessor()
|
||||
@@ -132,14 +167,14 @@ def test_unregistered_currency(mocker):
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 7,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 7,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert ret["items"][0]["snippet"]["superChatDetails"]["currency"] == "[UNREGISTERD]"
|
||||
|
||||
|
||||
def _open_file(path):
|
||||
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
227
tests/test_default_processor.py
Normal file
227
tests/test_default_processor.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.default.processor import DefaultProcessor
|
||||
|
||||
|
||||
TEST_TIMETSTAMP = 1570678496000000
|
||||
|
||||
|
||||
def get_local_datetime(timestamp):
|
||||
dt = datetime.fromtimestamp(timestamp / 1000000)
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
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.id == "dummy_id"
|
||||
assert ret.message == "dummy_message"
|
||||
assert ret.timestamp == 1570678496000
|
||||
assert ret.datetime == get_local_datetime(TEST_TIMETSTAMP)
|
||||
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.type == "textMessage"
|
||||
assert ret.id == "dummy_id"
|
||||
assert ret.message == "dummy_message"
|
||||
assert ret.messageEx == ["dummy_message"]
|
||||
assert ret.timestamp == 1570678496000
|
||||
assert ret.datetime == get_local_datetime(TEST_TIMETSTAMP)
|
||||
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]
|
||||
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 == get_local_datetime(TEST_TIMETSTAMP)
|
||||
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]
|
||||
assert ret.type == "superSticker"
|
||||
assert ret.id == "dummy_id"
|
||||
assert ret.message == ""
|
||||
assert ret.messageEx == []
|
||||
assert ret.timestamp == 1570678496000
|
||||
assert ret.datetime == get_local_datetime(TEST_TIMETSTAMP)
|
||||
assert ret.elapsedTime == ""
|
||||
assert ret.amountValue == 200
|
||||
assert ret.amountString == "¥200"
|
||||
assert ret.currency == "JPY"
|
||||
assert ret.bgColor == 4278237396
|
||||
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]
|
||||
assert ret.type == "newSponsor"
|
||||
assert ret.id == "dummy_id"
|
||||
assert ret.message == "新規メンバー"
|
||||
assert ret.messageEx == ["新規メンバー"]
|
||||
assert ret.timestamp == 1570678496000
|
||||
assert ret.datetime == get_local_datetime(TEST_TIMETSTAMP)
|
||||
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]
|
||||
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 == get_local_datetime(TEST_TIMETSTAMP)
|
||||
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()
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
@@ -1,60 +1,66 @@
|
||||
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 _open_file(path):
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_overlap():
|
||||
"""
|
||||
test overlap data
|
||||
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))
|
||||
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)
|
||||
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.
|
||||
# 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.
|
||||
|
||||
# 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))
|
||||
json.loads(_open_file(
|
||||
"tests/testdata/extract_duplcheck/head/" + filename))
|
||||
)[1]
|
||||
|
||||
"""
|
||||
@@ -69,25 +75,26 @@ def test_duplicate_head():
|
||||
result : [2] , [4] , [5]
|
||||
"""
|
||||
|
||||
#chat data offsets are ignored.
|
||||
# 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"))
|
||||
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[0].last == blocks[2].last
|
||||
assert result[1].first == blocks[4].first
|
||||
assert result[1].last == blocks[4].last
|
||||
assert result[1].last == blocks[4].last
|
||||
assert result[2].first == blocks[5].first
|
||||
assert result[2].last == blocks[5].last
|
||||
assert result[2].last == blocks[5].last
|
||||
|
||||
|
||||
def test_duplicate_tail():
|
||||
"""
|
||||
@@ -103,26 +110,25 @@ def test_duplicate_tail():
|
||||
"""
|
||||
def load_chatdata(filename):
|
||||
return parser.parse(
|
||||
json.loads(_open_file("tests/testdata/extract_duplcheck/head/"+filename))
|
||||
json.loads(_open_file(
|
||||
"tests/testdata/extract_duplcheck/head/" + filename))
|
||||
)[1]
|
||||
#chat data offsets are ignored.
|
||||
# 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"))
|
||||
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[0].last == blocks[0].last
|
||||
assert result[1].first == blocks[2].first
|
||||
assert result[1].last == blocks[2].last
|
||||
assert result[1].last == blocks[2].last
|
||||
assert result[2].first == blocks[4].first
|
||||
assert result[2].last == blocks[4].last
|
||||
|
||||
|
||||
assert result[2].last == blocks[4].last
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
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
|
||||
from pytchat.tool.extract.patch import Patch, split
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def load_chatdata(filename):
|
||||
return parser.parse(
|
||||
json.loads(_open_file("tests/testdata/fetch_patch/"+filename))
|
||||
)[1]
|
||||
return parser.parse(
|
||||
json.loads(_open_file("tests/testdata/fetch_patch/" + filename))
|
||||
)[1]
|
||||
|
||||
|
||||
def test_split_0():
|
||||
@@ -61,20 +57,23 @@ def test_split_0():
|
||||
@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)
|
||||
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)
|
||||
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
|
||||
assert parent.during_split is False
|
||||
assert child.during_split is False
|
||||
|
||||
|
||||
def test_split_1():
|
||||
"""patch.first <= parent_block.last
|
||||
@@ -119,14 +118,15 @@ def test_split_1():
|
||||
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
|
||||
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
|
||||
assert parent.during_split is False
|
||||
assert child.during_split is True # exclude during_split sequence
|
||||
|
||||
|
||||
def test_split_2():
|
||||
"""child_block.end < patch.last:
|
||||
@@ -174,7 +174,7 @@ def test_split_2():
|
||||
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||
first=32500, last=34000, continuation='patch')
|
||||
|
||||
split(parent,child,patch)
|
||||
split(parent, child, patch)
|
||||
|
||||
assert child.continuation is None
|
||||
assert parent.last < child.first
|
||||
@@ -182,8 +182,9 @@ def test_split_2():
|
||||
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
|
||||
assert parent.during_split is False
|
||||
assert child.during_split is False
|
||||
|
||||
|
||||
def test_split_none():
|
||||
"""patch.last <= parent_block.last
|
||||
@@ -193,7 +194,7 @@ def test_split_none():
|
||||
and parent.block.last exceeds patch.first.
|
||||
|
||||
In this case, fetched patch is all discarded,
|
||||
and worker searches other processing block again.
|
||||
and worker searches other processing block again.
|
||||
|
||||
~~~~~~ before ~~~~~~
|
||||
|
||||
@@ -229,10 +230,10 @@ def test_split_none():
|
||||
patch = Patch(chats=load_chatdata('pt0-5.json'),
|
||||
first=32500, last=34000, continuation='patch')
|
||||
|
||||
split(parent,child,patch)
|
||||
split(parent, child, patch)
|
||||
|
||||
assert parent.last == 40000 #no change
|
||||
assert parent.end == 60000 #no change
|
||||
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
|
||||
assert parent.during_split is False
|
||||
assert child.during_split is 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
|
||||
@@ -1,53 +1,48 @@
|
||||
import pytest
|
||||
from pytchat.parser.live import Parser
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import asyncio,aiohttp
|
||||
|
||||
from aioresponses import aioresponses
|
||||
from pytchat.core_async.livechat import LiveChatAsync
|
||||
from pytchat.exceptions import (
|
||||
NoLivechatRendererException,NoYtinitialdataException,
|
||||
ResponseContextError,NoContentsException)
|
||||
|
||||
|
||||
from pytest_httpx import HTTPXMock
|
||||
from concurrent.futures import CancelledError
|
||||
from pytchat.core_multithread.livechat import LiveChat
|
||||
import unittest
|
||||
from unittest import TestCase
|
||||
from pytchat.core_async.livechat import LiveChatAsync
|
||||
from pytchat.exceptions import ResponseContextError
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@aioresponses()
|
||||
def test_Async(*mock):
|
||||
vid=''
|
||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||
_text = json.loads(_text)
|
||||
mock[0].get(f"https://www.youtube.com/live_chat?v={vid}&is_popout=1", status=200, body=_text)
|
||||
|
||||
def add_response_file(httpx_mock: HTTPXMock, jsonfile_path: str):
|
||||
testdata = json.loads(_open_file(jsonfile_path))
|
||||
httpx_mock.add_response(json=testdata)
|
||||
|
||||
|
||||
def test_async(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/paramgen_firstread.json')
|
||||
|
||||
async def test_loop():
|
||||
try:
|
||||
chat = LiveChatAsync(video_id='__test_id__')
|
||||
_ = await chat.get()
|
||||
assert chat.is_alive()
|
||||
chat.terminate()
|
||||
assert not chat.is_alive()
|
||||
except ResponseContextError:
|
||||
assert False
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
chat = LiveChatAsync(video_id='')
|
||||
loop.run_until_complete(test_loop())
|
||||
except CancelledError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_multithread(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/paramgen_firstread.json')
|
||||
try:
|
||||
chat = LiveChat(video_id='__test_id__')
|
||||
_ = chat.get()
|
||||
assert chat.is_alive()
|
||||
chat.terminate()
|
||||
assert not chat.is_alive()
|
||||
except ResponseContextError:
|
||||
assert not chat.is_alive()
|
||||
|
||||
def test_MultiThread(mocker):
|
||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||
_text = json.loads(_text)
|
||||
responseMock = mocker.Mock()
|
||||
responseMock.status_code = 200
|
||||
responseMock.text = _text
|
||||
mocker.patch('requests.Session.get').return_value = responseMock
|
||||
try:
|
||||
chat = LiveChatAsync(video_id='')
|
||||
assert chat.is_alive()
|
||||
chat.terminate()
|
||||
assert not chat.is_alive()
|
||||
except ResponseContextError:
|
||||
chat.terminate()
|
||||
assert not chat.is_alive()
|
||||
|
||||
|
||||
|
||||
assert False
|
||||
|
||||
@@ -1,125 +1,113 @@
|
||||
import asyncio, aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
import time
|
||||
from aioresponses import aioresponses
|
||||
from pytest_httpx import HTTPXMock
|
||||
from concurrent.futures import CancelledError
|
||||
from unittest import TestCase
|
||||
from pytchat.core_multithread.livechat import LiveChat
|
||||
from pytchat.core_async.livechat import LiveChatAsync
|
||||
from pytchat.exceptions import (
|
||||
NoLivechatRendererException,NoYtinitialdataException,
|
||||
ResponseContextError,NoContentsException)
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.dummy_processor import DummyProcessor
|
||||
|
||||
|
||||
def _open_file(path):
|
||||
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
@aioresponses()
|
||||
def test_async_live_stream(*mock):
|
||||
|
||||
async def test_loop(*mock):
|
||||
pattern = re.compile(r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
|
||||
_text = _open_file('tests/testdata/test_stream.json')
|
||||
mock[0].get(pattern, status=200, body=_text)
|
||||
chat = LiveChatAsync(video_id='', processor = DummyProcessor())
|
||||
def add_response_file(httpx_mock: HTTPXMock, jsonfile_path: str):
|
||||
testdata = json.loads(_open_file(jsonfile_path))
|
||||
httpx_mock.add_response(json=testdata)
|
||||
|
||||
|
||||
def test_async_live_stream(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/test_stream.json')
|
||||
|
||||
async def test_loop():
|
||||
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"
|
||||
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))
|
||||
loop.run_until_complete(test_loop())
|
||||
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)
|
||||
def test_async_replay_stream(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/finished_live.json')
|
||||
add_response_file(httpx_mock, 'tests/testdata/chatreplay.json')
|
||||
|
||||
chat = LiveChatAsync(video_id='', processor = DummyProcessor())
|
||||
chats = await chat.get()
|
||||
rawdata = chats[0]["chatdata"]
|
||||
#assert fetching replaychat data
|
||||
assert list(rawdata[0]["addChatItemAction"]["item"].keys())[0] == "liveChatTextMessageRenderer"
|
||||
assert list(rawdata[14]["addChatItemAction"]["item"].keys())[0] == "liveChatPaidMessageRenderer"
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(test_loop(*mock))
|
||||
except CancelledError:
|
||||
assert True
|
||||
|
||||
@aioresponses()
|
||||
def test_async_force_replay(*mock):
|
||||
|
||||
async def test_loop(*mock):
|
||||
pattern_live = re.compile(r'^https://www.youtube.com/live_chat/get_live_chat\?continuation=.*$')
|
||||
pattern_replay = re.compile(r'^https://www.youtube.com/live_chat_replay/get_live_chat_replay\?continuation=.*$')
|
||||
#valid live data, but force_replay = True
|
||||
_text_live = _open_file('tests/testdata/test_stream.json')
|
||||
#valid replay data
|
||||
_text_replay = _open_file('tests/testdata/chatreplay.json')
|
||||
|
||||
mock[0].get(pattern_live, status=200, body=_text_live)
|
||||
mock[0].get(pattern_replay, status=200, body=_text_replay)
|
||||
#force replay
|
||||
chat = LiveChatAsync(video_id='', processor = DummyProcessor(), force_replay = True)
|
||||
async def test_loop():
|
||||
chat = LiveChatAsync(video_id='__test_id__', processor=DummyProcessor())
|
||||
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"
|
||||
|
||||
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))
|
||||
loop.run_until_complete(test_loop())
|
||||
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
|
||||
def test_async_force_replay(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/test_stream.json')
|
||||
add_response_file(httpx_mock, 'tests/testdata/chatreplay.json')
|
||||
|
||||
chat = LiveChat(video_id='test_id', processor = DummyProcessor())
|
||||
async def test_loop():
|
||||
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())
|
||||
except CancelledError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_multithread_live_stream(httpx_mock: HTTPXMock):
|
||||
add_response_file(httpx_mock, 'tests/testdata/test_stream.json')
|
||||
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"
|
||||
# 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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ def test_liveparam_0(mocker):
|
||||
_ts1= 1546268400
|
||||
param = liveparam._build("01234567890",
|
||||
*([_ts1*1000000 for i in range(5)]), topchat_only=False)
|
||||
test_param="0ofMyAPiARp8Q2c4S0RRb0xNREV5TXpRMU5qYzRPVEFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5TURFeU16UTFOamM0T1RBbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAuNbVqsrfAjAAOABAAkorCAEQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgLjW1arK3wJYA1CAuNbVqsrfAliAuNbVqsrfAmgBggEECAEQAIgBAKABgLjW1arK3wI%3D"
|
||||
test_param="0ofMyANcGhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09KIC41tWqyt8CQAFKC1CAuNbVqsrfAlgDUIC41tWqyt8CWIC41tWqyt8CaAGCAQIIAZoBAKABgLjW1arK3wI%3D"
|
||||
assert test_param == param
|
||||
@@ -1,44 +1,40 @@
|
||||
import pytest
|
||||
from pytchat.parser.live import Parser
|
||||
import json
|
||||
import asyncio,aiohttp
|
||||
from aioresponses import aioresponses
|
||||
from pytchat.exceptions import (
|
||||
NoLivechatRendererException,NoYtinitialdataException,
|
||||
ResponseContextError, NoContentsException)
|
||||
from pytchat.exceptions import NoContents
|
||||
|
||||
|
||||
parser = Parser(is_replay=False)
|
||||
|
||||
|
||||
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()
|
||||
parser = Parser(is_replay = False)
|
||||
|
||||
@aioresponses()
|
||||
|
||||
def test_finishedlive(*mock):
|
||||
'''配信が終了した動画を正しく処理できるか'''
|
||||
|
||||
_text = _open_file('tests/testdata/finished_live.json')
|
||||
_text = json.loads(_text)
|
||||
|
||||
try:
|
||||
try:
|
||||
parser.parse(parser.get_contents(_text))
|
||||
assert False
|
||||
except NoContentsException:
|
||||
except NoContents:
|
||||
assert True
|
||||
|
||||
@aioresponses()
|
||||
|
||||
def test_parsejson(*mock):
|
||||
'''jsonを正常にパースできるか'''
|
||||
|
||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||
_text = json.loads(_text)
|
||||
|
||||
try:
|
||||
try:
|
||||
parser.parse(parser.get_contents(_text))
|
||||
jsn = _text
|
||||
timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"]
|
||||
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"]
|
||||
assert 5035 == timeout
|
||||
assert "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D" == continuation
|
||||
except:
|
||||
assert False
|
||||
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"][
|
||||
"continuations"][0]["timedContinuationData"]["continuation"]
|
||||
assert timeout == 5035
|
||||
assert continuation == "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D"
|
||||
except Exception:
|
||||
assert False
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import json
|
||||
import pytest
|
||||
import asyncio,aiohttp
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.compatible.processor import CompatibleProcessor
|
||||
from pytchat.exceptions import (
|
||||
NoLivechatRendererException,NoYtinitialdataException,
|
||||
ResponseContextError, NoContentsException)
|
||||
|
||||
from pytchat.processors.speed.calculator import SpeedCalculator
|
||||
|
||||
parser = Parser(is_replay =False)
|
||||
parser = Parser(is_replay=False)
|
||||
|
||||
|
||||
def test_speed_1(mocker):
|
||||
'''test speed calculation with normal json.
|
||||
@@ -23,13 +17,14 @@ def test_speed_1(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 10,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 10,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert 30 == ret
|
||||
|
||||
|
||||
def test_speed_2(mocker):
|
||||
'''test speed calculation with no valid chat data.
|
||||
'''
|
||||
@@ -39,13 +34,14 @@ def test_speed_2(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 10,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 10,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert 0 == ret
|
||||
|
||||
assert ret == 0
|
||||
|
||||
|
||||
def test_speed_3(mocker):
|
||||
'''test speed calculation with empty data.
|
||||
'''
|
||||
@@ -55,14 +51,14 @@ def test_speed_3(mocker):
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json)))
|
||||
data = {
|
||||
"video_id" : "",
|
||||
"timeout" : 10,
|
||||
"chatdata" : chatdata
|
||||
"video_id": "",
|
||||
"timeout": 10,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert 0 == ret
|
||||
|
||||
assert ret == 0
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
from json.decoder import JSONDecodeError
|
||||
from pytchat.tool.videoinfo import VideoInfo
|
||||
from pytchat.exceptions import InvalidVideoIdException
|
||||
import pytest
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
mocker.patch('httpx.Client.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'
|
||||
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'''
|
||||
info = VideoInfo('__test_id__')
|
||||
'''live page: duration==0'''
|
||||
assert info.get_duration() == 0
|
||||
assert info.video_id == 'test_id'
|
||||
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'
|
||||
@@ -38,25 +42,60 @@ def test_live_page(mocker):
|
||||
' - 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')
|
||||
_ = 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'
|
||||
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
|
||||
|
||||
|
||||
def test_collapsed_data(mocker):
|
||||
'''Test case the video page's info is collapsed.'''
|
||||
_set_test_data(
|
||||
'tests/testdata/videoinfo/collapsed_page.txt', mocker)
|
||||
try:
|
||||
_ = VideoInfo('__test_id__')
|
||||
assert False
|
||||
except JSONDecodeError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_pattern_unmatch(mocker):
|
||||
'''Test case the pattern for extraction is unmatched.'''
|
||||
_set_test_data(
|
||||
'tests/testdata/videoinfo/pattern_unmatch.txt', mocker)
|
||||
try:
|
||||
_ = VideoInfo('__test_id__')
|
||||
assert False
|
||||
except JSONDecodeError:
|
||||
assert True
|
||||
|
||||
|
||||
def test_extradata_handling(mocker):
|
||||
'''Test case the extracted data are JSON lines.'''
|
||||
_set_test_data(
|
||||
'tests/testdata/videoinfo/extradata_page.txt', mocker)
|
||||
try:
|
||||
_ = VideoInfo('__test_id__')
|
||||
assert True
|
||||
except JSONDecodeError as e:
|
||||
print(e.doc)
|
||||
assert False
|
||||
|
||||
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
1
tests/testdata/default/jsonified_item.json
vendored
Normal file
1
tests/testdata/default/jsonified_item.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"author": {"badgeUrl": "", "type": "", "isVerified": false, "isChatOwner": false, "isChatSponsor": false, "isChatModerator": false, "channelId": "author_channel_id", "channelUrl": "http://www.youtube.com/channel/author_channel_id", "name": "author_name", "imageUrl": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"}, "type": "superChat", "id": "dummy_id", "timestamp": 1570678496000, "elapsedTime": "", "datetime": "2019-10-10 12:34:56", "message": "dummy_message", "messageEx": ["dummy_message"], "amountValue": 800.0, "amountString": "¥800", "currency": "JPY", "bgColor": 4280150454, "colors": {"headerBackgroundColor": 4278239141, "headerTextColor": 4278190080, "bodyBackgroundColor": 4280150454, "bodyTextColor": 4278190080, "timestampColor": 2147483648, "authorNameTextColor": 2315255808}}
|
||||
1
tests/testdata/default/jsonified_list.json
vendored
Normal file
1
tests/testdata/default/jsonified_list.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[{"author": {"badgeUrl": "", "type": "", "isVerified": false, "isChatOwner": false, "isChatSponsor": false, "isChatModerator": false, "channelId": "author_channel_id", "channelUrl": "http://www.youtube.com/channel/author_channel_id", "name": "author_name", "imageUrl": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg"}, "type": "superChat", "id": "dummy_id", "timestamp": 1570678496000, "elapsedTime": "", "datetime": "2019-10-10 12:34:56", "message": "dummy_message", "messageEx": ["dummy_message"], "amountValue": 800.0, "amountString": "¥800", "currency": "JPY", "bgColor": 4280150454, "colors": {"headerBackgroundColor": 4278239141, "headerTextColor": 4278190080, "bodyBackgroundColor": 4280150454, "bodyTextColor": 4278190080, "timestampColor": 2147483648, "authorNameTextColor": 2315255808}}]
|
||||
100
tests/testdata/default/newSponsor_current.json
vendored
Normal file
100
tests/testdata/default/newSponsor_current.json
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatMembershipItemRenderer": {
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"headerSubtext": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "新規メンバー"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "author_name"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorBadges": [
|
||||
{
|
||||
"liveChatAuthorBadgeRenderer": {
|
||||
"customThumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/X=s32-c-k"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/X=s64-c-k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tooltip": "新規メンバー",
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "新規メンバー"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
tests/testdata/default/newSponsor_lagacy.json
vendored
Normal file
82
tests/testdata/default/newSponsor_lagacy.json
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatLegacyPaidMessageRenderer": {
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"eventText": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "新規メンバー"
|
||||
}
|
||||
]
|
||||
},
|
||||
"detailText": {
|
||||
"simpleText": "ようこそ、author_name!"
|
||||
},
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"contextMenuEndpoint": {
|
||||
"clickTrackingParams": "___clickTrackingParams___",
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
tests/testdata/default/replay_member_text.json
vendored
Normal file
112
tests/testdata/default/replay_member_text.json
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"webResponseContextExtensionData": "data"
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"liveChatReplayContinuationData": {
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"clickTrackingParams": "___clickTrackingParams___",
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"authorBadges": [
|
||||
{
|
||||
"liveChatAuthorBadgeRenderer": {
|
||||
"customThumbnail": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/X=s16-c-k"
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/X=s32-c-k"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tooltip": "メンバー(1 か月)",
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "メンバー(1 か月)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
},
|
||||
"timestampText": {
|
||||
"simpleText": "1:23:45"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "dummy_client_id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"videoOffsetTimeMsec": "5025120"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
tests/testdata/default/superchat.json
vendored
Normal file
184
tests/testdata/default/superchat.json
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥800"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "dummy_message"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"addLiveChatTickerItemAction": {
|
||||
"item": {
|
||||
"liveChatTickerPaidMessageItemRenderer": {
|
||||
"id": "dummy_id",
|
||||
"amount": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"amountTextColor": 4278190080,
|
||||
"startBackgroundColor": 4280150454,
|
||||
"endBackgroundColor": 4278239141,
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"durationSec": 120,
|
||||
"showItemEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"showLiveChatItemEndpoint": {
|
||||
"renderer": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "dummy_message"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
|
||||
"fullDurationSec": 120
|
||||
}
|
||||
},
|
||||
"durationSec": "120"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
tests/testdata/default/supersticker.json
vendored
Normal file
99
tests/testdata/default/supersticker.json
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidStickerRenderer": {
|
||||
"id": "dummy_id",
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
},
|
||||
"timestampUsec": 1570678496000000,
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "author_name"
|
||||
},
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"sticker": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/param_s=s72-rp",
|
||||
"width": 72,
|
||||
"height": 72
|
||||
},
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/param_s=s144-rp",
|
||||
"width": 144,
|
||||
"height": 144
|
||||
}
|
||||
],
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "___sticker_label___"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moneyChipBackgroundColor": 4278248959,
|
||||
"moneyChipTextColor": 4278190080,
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥200"
|
||||
},
|
||||
"stickerDisplayWidth": 72,
|
||||
"stickerDisplayHeight": 72,
|
||||
"backgroundColor": 4278237396,
|
||||
"authorNameTextColor": 3003121664
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
tests/testdata/default/textmessage.json
vendored
Normal file
79
tests/testdata/default/textmessage.json
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"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/s64-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 1570678496000000,
|
||||
"authorExternalChannelId": "author_channel_id",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "dummy_client_id"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tests/testdata/videoinfo/collapsed_page.txt
vendored
Normal file
15
tests/testdata/videoinfo/collapsed_page.txt
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user