Compare commits
542 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808e599be6 | ||
|
|
5cb6f7f123 | ||
|
|
05de644d77 | ||
|
|
a2f1c658f0 | ||
|
|
b908855566 | ||
|
|
bf68859f38 | ||
|
|
8d93bfcb95 | ||
|
|
78fbe97b66 | ||
|
|
b7f2967a4f | ||
|
|
166a256c1c | ||
|
|
0a8ff3abdc | ||
|
|
9b38a5428d | ||
|
|
9311bf1993 | ||
|
|
ee839da7c9 | ||
|
|
2ae77b3850 | ||
|
|
afd7cea635 | ||
|
|
9018ff9ee4 | ||
|
|
b676912d64 | ||
|
|
d75e1db304 | ||
|
|
0d7dd26ede | ||
|
|
24b9f45e4a | ||
|
|
821cc57024 | ||
|
|
f050f5d650 | ||
|
|
c4eb78e112 | ||
|
|
40b0138a92 | ||
|
|
1d80dce913 | ||
|
|
8b18f4476a | ||
|
|
35a27fee3e | ||
|
|
eca766ff1a | ||
|
|
139dc7fce2 | ||
|
|
2c8a883ee3 | ||
|
|
865e4b5fab | ||
|
|
02d48ceccc | ||
|
|
bc3f16e86b | ||
|
|
1e6ce58f8b | ||
|
|
4db9486853 | ||
|
|
775b79642e | ||
|
|
423a128882 | ||
|
|
aaf9860bdc | ||
|
|
83ad4dcf1f | ||
|
|
765251b872 | ||
|
|
7ea88fead2 | ||
|
|
ea67e3e54e | ||
|
|
a5c7ba52c8 | ||
|
|
7cf780ee87 | ||
|
|
c37201fa03 | ||
|
|
6fcc1393de | ||
|
|
a474899268 | ||
|
|
3f72eb0e00 | ||
|
|
661d1e4b81 | ||
|
|
4652a56bc6 | ||
|
|
966320cab5 | ||
|
|
35218a66da | ||
|
|
3432609588 | ||
|
|
3ad6b7e845 | ||
|
|
48669e5f53 | ||
|
|
7b0708ec46 | ||
|
|
f46df3ae42 | ||
|
|
96c028bd5d | ||
|
|
402dc15d7a | ||
|
|
6088ab6932 | ||
|
|
13812bdad3 | ||
|
|
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 | ||
|
|
3243d69d7a | ||
|
|
6e1b735ebc | ||
|
|
c54481dad5 | ||
|
|
78604c84d4 | ||
|
|
21d93613a2 | ||
|
|
56bf721330 | ||
|
|
5f50598f79 | ||
|
|
5e8c438c6b | ||
|
|
23e47f6fb0 | ||
|
|
74dfe0a612 | ||
|
|
725af25d81 | ||
|
|
316fc5594a | ||
|
|
44dffc7650 | ||
|
|
102d8c48c4 | ||
|
|
f8822a053f | ||
|
|
9d624f771a | ||
|
|
778d4db28b | ||
|
|
36e0fd5c54 | ||
|
|
4252643273 | ||
|
|
c88fd8bc4e | ||
|
|
af3b6d4271 | ||
|
|
331e825c97 | ||
|
|
4019ad4b9d | ||
|
|
1074178afc | ||
|
|
55a58f532d | ||
|
|
b302454083 | ||
|
|
ff9e7de796 | ||
|
|
fe2047502a | ||
|
|
5480e3e9ed | ||
|
|
18c08f45ad | ||
|
|
a9831c6a27 | ||
|
|
60976b2584 | ||
|
|
92abf7499c | ||
|
|
4416e1a79c | ||
|
|
f7f9c1cda3 | ||
|
|
de35537be8 | ||
|
|
61d4e06470 | ||
|
|
3c95242ddf | ||
|
|
af4afb4636 | ||
|
|
05e1c908a5 | ||
|
|
e770d95fe8 | ||
|
|
eae485b914 | ||
|
|
d8c1c4491d | ||
|
|
3e941c2cf1 | ||
|
|
8b617551ad | ||
|
|
c4cf424702 | ||
|
|
6fdb3bf8cf | ||
|
|
b1292b4329 | ||
|
|
339d04ad75 | ||
|
|
abb7565e3a | ||
|
|
ee77807dbd | ||
|
|
2c598bc8f7 | ||
|
|
c7bfae9f2a | ||
|
|
eaa7bdc8b6 | ||
|
|
4a8e353098 | ||
|
|
24f08ecbdb | ||
|
|
e8510f1116 | ||
|
|
f1d8393971 | ||
|
|
04aedc82e8 | ||
|
|
228773295d | ||
|
|
59defc568c | ||
|
|
9de75788f2 | ||
|
|
76f0c0e658 | ||
|
|
0d8ecb778f | ||
|
|
a3eca8f05d | ||
|
|
bbf7a2906a | ||
|
|
1862b83eac | ||
|
|
053ff5291f | ||
|
|
4e47d4a262 | ||
|
|
436e8df4c9 | ||
|
|
5ab8cfe736 | ||
|
|
15b517e905 | ||
|
|
214a3d2be3 | ||
|
|
e968325d1f | ||
|
|
a56dc89477 | ||
|
|
38253e1d18 | ||
|
|
cc78551e90 | ||
|
|
6e37ef5d4f | ||
|
|
c126d5b825 | ||
|
|
a89503fe9e | ||
|
|
1d7678c954 | ||
|
|
dea98c33d7 | ||
|
|
5ba61db4f3 | ||
|
|
03b901d59c | ||
|
|
540f16c1a0 | ||
|
|
cc8bba8f63 | ||
|
|
22b3ec2994 | ||
|
|
9d494446e1 | ||
|
|
956c7e2640 | ||
|
|
03537c0a06 | ||
|
|
f7d1830226 | ||
|
|
76b126faf2 | ||
|
|
bbd01d6523 | ||
|
|
f8fa0e394e | ||
|
|
efdf07e3de | ||
|
|
2573cc18de | ||
|
|
1c5852421b | ||
|
|
970d111e1b | ||
|
|
1643dd1ad1 | ||
|
|
0272319fa6 | ||
|
|
fb0edef136 | ||
|
|
260a2b35a9 | ||
|
|
e03d39475e | ||
|
|
2462b8aca0 | ||
|
|
a1024c8734 | ||
|
|
6b3ca00d35 | ||
|
|
385634b709 | ||
|
|
c1a78a2743 | ||
|
|
7961801e0c | ||
|
|
5fe4e7af04 | ||
|
|
892dfb8a91 | ||
|
|
fddab22a1f | ||
|
|
7194948066 | ||
|
|
a836d92194 | ||
|
|
c408cb2713 | ||
|
|
c3d2238ead | ||
|
|
6c8d390fc7 | ||
|
|
ff1ee70d7e | ||
|
|
404623546e | ||
|
|
3f9f64d19c | ||
|
|
7996c6adad | ||
|
|
50d55da7dc | ||
|
|
c92e735715 | ||
|
|
d4a1d00e28 | ||
|
|
60c389f3f7 | ||
|
|
705bfe0bed | ||
|
|
0f7a0218b6 | ||
|
|
89d2f8978f | ||
|
|
6befc2de95 | ||
|
|
a0c5ea035a | ||
|
|
3b27c81166 | ||
|
|
26fefddddf | ||
|
|
5d86fb4b71 | ||
|
|
b5e302cdf3 | ||
|
|
5d228589f1 | ||
|
|
fd8ecec0c5 | ||
|
|
a1e48b56e6 | ||
|
|
9c41536533 | ||
|
|
2c684d04b5 | ||
|
|
30708470f2 | ||
|
|
d742a9fdf3 | ||
|
|
2fdd834caf | ||
|
|
4c558491a3 | ||
|
|
0fc9d14780 | ||
|
|
18400724b1 | ||
|
|
7b7323abf8 | ||
|
|
fc5979c025 | ||
|
|
f4dc5e9d4a | ||
|
|
347707a514 | ||
|
|
7766a39c9c | ||
|
|
48b6f2c24e | ||
|
|
907f8aba0b | ||
|
|
2616e4c4b5 | ||
|
|
d6ea673f98 | ||
|
|
2bb481a228 | ||
|
|
7308a87a61 | ||
|
|
9751289eca | ||
|
|
044fe97aa5 | ||
|
|
28e3289790 | ||
|
|
585a4be7dc | ||
|
|
b84a82341e | ||
|
|
b4f3307b1c | ||
|
|
be7ac97c62 | ||
|
|
f8de4e7e39 | ||
|
|
ac0f052aa0 | ||
|
|
1cc0338a8e | ||
|
|
f6b8229998 | ||
|
|
f8bcc8a453 | ||
|
|
f24c5f9e30 | ||
|
|
5268961854 | ||
|
|
733f754e11 | ||
|
|
582d0b749d | ||
|
|
b8bc00d880 | ||
|
|
ce96d94e23 | ||
|
|
7af92f14c0 | ||
|
|
7305e4178b | ||
|
|
a835d58e10 | ||
|
|
4e956b8d84 | ||
|
|
c4f1194a53 | ||
|
|
90b10a9f8f | ||
|
|
b576c3f928 | ||
|
|
c0728e1366 | ||
|
|
fff09d4c27 | ||
|
|
810b6c8c6b | ||
|
|
dfada86caf | ||
|
|
91aa944df5 | ||
|
|
6ac5191e85 | ||
|
|
fff3e0371f | ||
|
|
a70efe8a67 | ||
|
|
dc47f4debe | ||
|
|
ab5a2a8df2 | ||
|
|
5a79f26fa7 | ||
|
|
18666199b7 | ||
|
|
b357bccb98 | ||
|
|
3c1f079d5f | ||
|
|
289841a000 | ||
|
|
ee0ff7fe74 | ||
|
|
c0870ce537 | ||
|
|
de6ef2490e | ||
|
|
b8bdbdc36f | ||
|
|
9f5d3f323e | ||
|
|
cf9aae3322 | ||
|
|
6ac2315936 | ||
|
|
50c8e34080 | ||
|
|
2d3da91d51 | ||
|
|
3ac71985ff | ||
|
|
13bdf0376b | ||
|
|
b2ffdaec0c | ||
|
|
c85786679f | ||
|
|
c7a7886672 | ||
|
|
12996fb44d | ||
|
|
c884ef7288 | ||
|
|
2cd9e98fc2 | ||
|
|
2ac4c99ab4 | ||
|
|
51bf8ad738 | ||
|
|
2e70e74bcd | ||
|
|
a39d6cb420 | ||
|
|
5dd0cb45b7 | ||
|
|
24873651a6 | ||
|
|
0e060bf998 | ||
|
|
817fed9d1d | ||
|
|
823f7fefa4 | ||
|
|
aa894fc52b | ||
|
|
6d775e5cd0 | ||
|
|
53b70ed86b | ||
|
|
68c707b7d6 | ||
|
|
30aaa54a2f |
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, 3.9]
|
||||
|
||||
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.
|
||||
@@ -1,3 +1,5 @@
|
||||
include requirements.txt
|
||||
include requirements_test.txt
|
||||
|
||||
include README.md
|
||||
global-exclude tests/*
|
||||
global-exclude pytchat/testrun*.py
|
||||
13
Pipfile
Normal file
13
Pipfile
Normal file
@@ -0,0 +1,13 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
httpx = {extras = ["http2"], version = "0.16.1"}
|
||||
|
||||
[dev-packages]
|
||||
pytest-mock = "*"
|
||||
pytest-httpx = "*"
|
||||
wheel = "*"
|
||||
twine = "*"
|
||||
375
Pipfile.lock
generated
Normal file
375
Pipfile.lock
generated
Normal file
@@ -0,0 +1,375 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "e1eb34f14c75998519a90838b283ccd23bd168afa8e4837f956c5c4df66376f9"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
|
||||
"sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
|
||||
],
|
||||
"version": "==2020.11.8"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
|
||||
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
|
||||
],
|
||||
"version": "==0.11.0"
|
||||
},
|
||||
"h2": {
|
||||
"hashes": [
|
||||
"sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
|
||||
"sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
|
||||
],
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"hpack": {
|
||||
"hashes": [
|
||||
"sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
|
||||
"sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
|
||||
],
|
||||
"version": "==3.0.0"
|
||||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:420700af11db658c782f7e8fda34f9dcd95e3ee93944dd97d78cb70247e0cd06",
|
||||
"sha256:dd1d762d4f7c2702149d06be2597c35fb154c5eff9789a8c5823fbcf4d2978d6"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"httpx": {
|
||||
"extras": [
|
||||
"http2"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537",
|
||||
"sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.1"
|
||||
},
|
||||
"hyperframe": {
|
||||
"hashes": [
|
||||
"sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
|
||||
"sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
|
||||
],
|
||||
"version": "==5.2.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"rfc3986": {
|
||||
"extras": [
|
||||
"idna2008"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
|
||||
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
||||
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.2.0"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"atomicwrites": {
|
||||
"hashes": [
|
||||
"sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197",
|
||||
"sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"
|
||||
],
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.3.0"
|
||||
},
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080",
|
||||
"sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==3.2.1"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
|
||||
"sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
|
||||
],
|
||||
"version": "==2020.11.8"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
|
||||
"sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
|
||||
],
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==0.4.4"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
|
||||
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==0.16"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
|
||||
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
|
||||
],
|
||||
"version": "==0.11.0"
|
||||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:420700af11db658c782f7e8fda34f9dcd95e3ee93944dd97d78cb70247e0cd06",
|
||||
"sha256:dd1d762d4f7c2702149d06be2597c35fb154c5eff9789a8c5823fbcf4d2978d6"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"httpx": {
|
||||
"extras": [
|
||||
"http2"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537",
|
||||
"sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
|
||||
"sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"keyring": {
|
||||
"hashes": [
|
||||
"sha256:12de23258a95f3b13e5b167f7a641a878e91eab8ef16fafc077720a95e6115bb",
|
||||
"sha256:207bd66f2a9881c835dad653da04e196c678bf104f8252141d2d3c4f31051579"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==21.5.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236",
|
||||
"sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.7"
|
||||
},
|
||||
"pkginfo": {
|
||||
"hashes": [
|
||||
"sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193",
|
||||
"sha256:ce14d7296c673dc4c61c759a0b6c14bae34e34eb819c0017bb6ca5b7292c56e9"
|
||||
],
|
||||
"version": "==1.6.1"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
|
||||
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0",
|
||||
"sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.7.2"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe",
|
||||
"sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==6.1.2"
|
||||
},
|
||||
"pytest-httpx": {
|
||||
"hashes": [
|
||||
"sha256:0a7c56e559b23efbf857054cd74de60a7c540694a162423f89c70da6ad358d8e",
|
||||
"sha256:d32e8f6fb7e028f0313f5f5a2d463c8673eb43fd11a9bfe8527299717a7764c4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"pytest-mock": {
|
||||
"hashes": [
|
||||
"sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2",
|
||||
"sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.3.1"
|
||||
},
|
||||
"pywin32-ctypes": {
|
||||
"hashes": [
|
||||
"sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942",
|
||||
"sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"
|
||||
],
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"readme-renderer": {
|
||||
"hashes": [
|
||||
"sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d",
|
||||
"sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a"
|
||||
],
|
||||
"version": "==28.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
|
||||
"sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.25.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||
],
|
||||
"version": "==0.9.1"
|
||||
},
|
||||
"rfc3986": {
|
||||
"extras": [
|
||||
"idna2008"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
|
||||
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
||||
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:5c0d04e06ccc0da1bd3fa5ae4550effcce42fcad947b4a6cafa77bdc9b09ff22",
|
||||
"sha256:9e7b8ab0ecbdbf0595adadd5f0ebbb9e69010e0bd48bbb0c15e550bf2a5292df"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==4.54.0"
|
||||
},
|
||||
"twine": {
|
||||
"hashes": [
|
||||
"sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab",
|
||||
"sha256:ba9ff477b8d6de0c89dd450e70b2185da190514e91c42cc62f96850025c10472"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
|
||||
"sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.26.2"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
|
||||
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
|
||||
],
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"wheel": {
|
||||
"hashes": [
|
||||
"sha256:906864fb722c0ab5f2f9c35b2c65e3af3c009402c108a709c0aca27bc2c9187b",
|
||||
"sha256:aaef9b8c36db72f8bf7f1e54f85f875c4d466819940863ca0b3f3f77f0a1646f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.36.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
137
README.md
137
README.md
@@ -2,120 +2,66 @@ pytchat
|
||||
=======
|
||||
|
||||
pytchat is a python library for fetching youtube live chat.
|
||||
|
||||
|
||||
<br><br><br>
|
||||
## Description
|
||||
pytchat is a python library for fetching youtube live chat
|
||||
without using youtube api, Selenium or BeautifulSoup.
|
||||
without using Selenium or BeautifulSoup.
|
||||
|
||||
Other features:
|
||||
+ Customizable chat data processors including yt api compatible one.
|
||||
+ Customizable [chat data processors](https://github.com/taizan-hokuto/pytchat/wiki/ChatProcessor) including youtube api compatible one.
|
||||
+ Available on asyncio context.
|
||||
+ Quick fetching of initial chat data by generating continuation params
|
||||
instead of web scraping.
|
||||
|
||||
より詳細な説明は [wiki](https://github.com/taizan-hokuto/pytchat/wiki) をご参照ください。
|
||||
For more detailed information, see [wiki](https://github.com/taizan-hokuto/pytchat/wiki). <br>
|
||||
[wiki (Japanese)](https://github.com/taizan-hokuto/pytchat/wiki/Home_jp)
|
||||
|
||||
## Install
|
||||
```python
|
||||
pip install pytchat
|
||||
```
|
||||
## Demo
|
||||

|
||||
|
||||
## Examples
|
||||
### on-demand mode
|
||||
```python
|
||||
from pytchat import LiveChat
|
||||
|
||||
chat = LiveChat("G1w62uEMZ74")
|
||||
|
||||
### Fetch chat data (see [wiki](https://github.com/taizan-hokuto/pytchat/wiki/PytchatCore))
|
||||
```python
|
||||
import pytchat
|
||||
chat = pytchat.create(video_id="uIx8l2xlYVY")
|
||||
while chat.is_alive():
|
||||
data = chat.get()
|
||||
for c in data.items:
|
||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||
data.tick()
|
||||
for c in chat.get().sync_items():
|
||||
print(f"{c.datetime} [{c.author.name}]- {c.message}")
|
||||
```
|
||||
|
||||
### callback mode
|
||||
|
||||
### Output JSON format string (feature of [DefaultProcessor](https://github.com/taizan-hokuto/pytchat/wiki/DefaultProcessor))
|
||||
```python
|
||||
from pytchat import LiveChat
|
||||
import pytchat
|
||||
import time
|
||||
|
||||
chat = LiveChat("G1w62uEMZ74", callback = func)
|
||||
chat = pytchat.create(video_id="uIx8l2xlYVY")
|
||||
while chat.is_alive():
|
||||
#other background operation here.
|
||||
time.sleep(3)
|
||||
|
||||
def func(data):
|
||||
for c in data.items:
|
||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||
data.tick()
|
||||
```
|
||||
|
||||
### asyncio context:
|
||||
```python
|
||||
from pytchat import LiveChatAsync
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
chat = LiveChatAsync("G1w62uEMZ74", callback = func)
|
||||
while chat.is_alive():
|
||||
#other background operation here.
|
||||
await asyncio.sleep(3)
|
||||
|
||||
async def func(data):
|
||||
for c in data.items:
|
||||
print(f"{c.datetime} [{c.author.name}]-{c.message} {c.amountString}")
|
||||
await data.tick_async()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
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())
|
||||
'''
|
||||
```
|
||||
|
||||
|
||||
### youtube api compatible processor:
|
||||
```python
|
||||
from pytchat import LiveChat, CompatibleProcessor
|
||||
### other
|
||||
+ Fetch chat with a buffer ([LiveChat](https://github.com/taizan-hokuto/pytchat/wiki/LiveChat))
|
||||
|
||||
chat = LiveChat("G1w62uEMZ74",
|
||||
processor = CompatibleProcessor() )
|
||||
+ Use with asyncio ([LiveChatAsync](https://github.com/taizan-hokuto/pytchat/wiki/LiveChatAsync))
|
||||
|
||||
while chat.is_alive():
|
||||
data = chat.get()
|
||||
polling = data["pollingIntervalMillis"]/1000
|
||||
for c in data["items"]:
|
||||
if c.get("snippet"):
|
||||
print(f"[{c['authorDetails']['displayName']}]"
|
||||
f"-{c['snippet']['displayMessage']}")
|
||||
time.sleep(polling/len(data["items"]))
|
||||
+ YT API compatible chat processor ([CompatibleProcessor](https://github.com/taizan-hokuto/pytchat/wiki/CompatibleProcessor))
|
||||
|
||||
```
|
||||
### replay:
|
||||
```python
|
||||
from pytchat import ReplayChatAsync
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
chat = ReplayChatAsync("G1w62uEMZ74", seektime = 1000, callback = func)
|
||||
while chat.is_alive():
|
||||
#other background operation here.
|
||||
await asyncio.sleep(3)
|
||||
|
||||
async def func(data):
|
||||
for count in range(0,len(data.items)):
|
||||
c= data.items[count]
|
||||
if count!=len(data.items):
|
||||
tick=data.items[count+1].timestamp -data.items[count].timestamp
|
||||
else:
|
||||
tick=0
|
||||
print(f"<{c.timestampText}> [{c.author.name}]-{c.message} {c.amountString}")
|
||||
await asyncio.sleep(tick/1000)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
```
|
||||
|
||||
## Chatdata Structure of Default Processor
|
||||
Structure of each item which got from items() function.
|
||||
## Structure of Default Processor
|
||||
Each item can be got with `sync_items()` function.
|
||||
<table>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
@@ -137,6 +83,11 @@ Structure of each item which got from items() function.
|
||||
<td>str</td>
|
||||
<td>emojis are represented by ":(shortcut text):"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>messageEx</td>
|
||||
<td>str</td>
|
||||
<td>list of message texts and emoji dicts(id, txt, url).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>timestamp</td>
|
||||
<td>int</td>
|
||||
@@ -145,26 +96,26 @@ Structure of each item which got from items() function.
|
||||
<tr>
|
||||
<td>datetime</td>
|
||||
<td>str</td>
|
||||
<td>ex. "2019-10-10 12:34:56"</td>
|
||||
<td>e.g. "2019-10-10 12:34:56"</td>
|
||||
</tr>
|
||||
<td>timestampText</td>
|
||||
<td>elapsedTime</td>
|
||||
<td>str</td>
|
||||
<td>elapsed time. (ex. "1:02:27")</td>
|
||||
<td>elapsed time. (e.g. "1:02:27") *Replay Only.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amountValue</td>
|
||||
<td>float</td>
|
||||
<td>ex. 1,234.0</td>
|
||||
<td>e.g. 1,234.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amountString</td>
|
||||
<td>str</td>
|
||||
<td>ex. "$ 1,234"</td>
|
||||
<td>e.g. "$ 1,234"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>currency</td>
|
||||
<td>str</td>
|
||||
<td><a href="https://en.wikipedia.org/wiki/ISO_4217">ISO 4217 currency codes</a> (ex. "USD")</td>
|
||||
<td><a href="https://en.wikipedia.org/wiki/ISO_4217">ISO 4217 currency codes</a> (e.g. "USD")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>bgColor</td>
|
||||
@@ -193,7 +144,7 @@ Structure of author object.
|
||||
<tr>
|
||||
<td>channelId</td>
|
||||
<td>str</td>
|
||||
<td></td>
|
||||
<td>*chatter's channel ID.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>channelUrl</td>
|
||||
@@ -236,8 +187,4 @@ Structure of author object.
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
[taizan-hokuto](https://github.com/taizan-hokuto)
|
||||
|
||||
[twitter:@taizan205](https://twitter.com/taizan205)
|
||||
@@ -1,22 +1,44 @@
|
||||
"""
|
||||
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.3.0'
|
||||
__copyright__ = 'Copyright (C) 2019, 2020 taizan-hokuto'
|
||||
__version__ = '0.5.1'
|
||||
__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 (
|
||||
config,
|
||||
LiveChat,
|
||||
LiveChatAsync,
|
||||
ReplayChat,
|
||||
ReplayChatAsync,
|
||||
ChatProcessor,
|
||||
CompatibleProcessor,
|
||||
CompatibleProcessor,
|
||||
DummyProcessor,
|
||||
DefaultProcessor,
|
||||
HTMLArchiver,
|
||||
TSVArchiver,
|
||||
JsonfileArchiver,
|
||||
SimpleDisplayProcessor,
|
||||
JsonfileArchiveProcessor
|
||||
)
|
||||
SpeedCalculator,
|
||||
SuperchatCalculator,
|
||||
create
|
||||
)
|
||||
# flake8: noqa
|
||||
@@ -1,10 +1,34 @@
|
||||
from . import config
|
||||
from .core import create
|
||||
from .core_multithread.livechat import LiveChat
|
||||
from .core_async.livechat import LiveChatAsync
|
||||
from .core_multithread.replaychat import ReplayChat
|
||||
from .core_async.replaychat import ReplayChatAsync
|
||||
from .processors.chat_processor import ChatProcessor
|
||||
from .processors.default.processor import DefaultProcessor
|
||||
from .processors.compatible.processor import CompatibleProcessor
|
||||
from .processors.default.processor import DefaultProcessor
|
||||
from .processors.dummy_processor import DummyProcessor
|
||||
from .processors.html_archiver import HTMLArchiver
|
||||
from .processors.tsv_archiver import TSVArchiver
|
||||
from .processors.jsonfile_archiver import JsonfileArchiver
|
||||
from .processors.simple_display_processor import SimpleDisplayProcessor
|
||||
from .processors.jsonfile_archive_processor import JsonfileArchiveProcessor
|
||||
from .processors.speed.calculator import SpeedCalculator
|
||||
from .processors.superchat.calculator import SuperchatCalculator
|
||||
|
||||
|
||||
__all__ = [
|
||||
config,
|
||||
LiveChat,
|
||||
LiveChatAsync,
|
||||
ChatProcessor,
|
||||
CompatibleProcessor,
|
||||
DummyProcessor,
|
||||
DefaultProcessor,
|
||||
HTMLArchiver,
|
||||
TSVArchiver,
|
||||
JsonfileArchiver,
|
||||
SimpleDisplayProcessor,
|
||||
SpeedCalculator,
|
||||
SuperchatCalculator,
|
||||
create
|
||||
]
|
||||
|
||||
# flake8: noqa
|
||||
@@ -1,4 +1,14 @@
|
||||
import logging
|
||||
LOGGER_MODE = logging.DEBUG
|
||||
import logging # noqa
|
||||
from . import mylogger
|
||||
from base64 import a85decode as dc
|
||||
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/86.0.4240.183 Safari/537.36 Edg/86.0.622.63,gzip(gfe)',
|
||||
}
|
||||
|
||||
_sml = dc(b"BQS?8F#ks-GB\\6`H#IhIF^eo7@rH3;H#IhIF^eor06T''Ch\\'(?XmbXF>%9<FC/iuG%G#jBOQ!ICLqcS5tQB2;gCZ)?UdXC;f$GR3)MM2<(0>O7mh!,G@+K5?SO9T@okV").decode()
|
||||
_smr = dc(b"BQS?8F#ks-GB\\6`H#IhIF^eo7@rH3;H#IhIF^eor06T''Ch\\'(?XmbXF>%9<FC/iuG%G#jBOQ!iEb03+@<k(QAU-F)8U=fDGsP557S5F7CiNH7;)D3N77^*B6YU@\\?WfBr0emZX=#^").decode()
|
||||
|
||||
|
||||
def logger(module_name: str, loglevel=None):
|
||||
module_logger = mylogger.get_logger(module_name, loglevel=loglevel)
|
||||
return module_logger
|
||||
|
||||
38
pytchat/config/mylogger.py
Normal file
38
pytchat/config/mylogger.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from logging import NullHandler, getLogger, StreamHandler, FileHandler
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_logger(modname, loglevel=logging.DEBUG):
|
||||
logger = getLogger(modname)
|
||||
if loglevel is None:
|
||||
logger.addHandler(NullHandler())
|
||||
return logger
|
||||
logger.setLevel(loglevel)
|
||||
# create handler1 for showing info
|
||||
handler1 = StreamHandler()
|
||||
my_formatter = MyFormatter()
|
||||
handler1.setFormatter(my_formatter)
|
||||
|
||||
handler1.setLevel(loglevel)
|
||||
logger.addHandler(handler1)
|
||||
# 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 = (
|
||||
datetime.fromtimestamp(record.created)).strftime("%m-%d %H:%M:%S")
|
||||
module = (record.module).ljust(15)
|
||||
funcname = (record.funcName).ljust(18)
|
||||
lineno = str(record.lineno).rjust(4)
|
||||
message = record.getMessage()
|
||||
|
||||
return timestamp + '| ' + module + ' { ' + funcname + ':' + lineno + '} - ' + message
|
||||
7
pytchat/core/__init__.py
Normal file
7
pytchat/core/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .pytchat import PytchatCore
|
||||
from .. util import extract_video_id
|
||||
|
||||
|
||||
def create(video_id: str, **kwargs):
|
||||
_vid = extract_video_id(video_id)
|
||||
return PytchatCore(_vid, **kwargs)
|
||||
207
pytchat/core/pytchat.py
Normal file
207
pytchat/core/pytchat.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import httpx
|
||||
import json
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
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 .. import util
|
||||
|
||||
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.
|
||||
'''
|
||||
|
||||
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 = util.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 = config._sml
|
||||
self._topchat_only = topchat_only
|
||||
self._dat = ''
|
||||
self._last_offset_ms = 0
|
||||
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, past_sec=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')
|
||||
self._last_offset_ms = metadata.get('last_offset_ms', 0)
|
||||
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, replay=self._is_replay, offset_ms=self._last_offset_ms)
|
||||
)
|
||||
contents, dat = self._parser.get_contents(livechat_json)
|
||||
if self._dat == '' and dat:
|
||||
self._dat = dat
|
||||
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 = config._smr
|
||||
continuation = arcparam.getparam(
|
||||
self._video_id, self.seektime, self._topchat_only, util.get_channelid(client, self._video_id))
|
||||
livechat_json = self._get_livechat_json(continuation, client, replay=True, offset_ms=self.seektime * 1000)
|
||||
reload_continuation = self._parser.reload_continuation(
|
||||
self._parser.get_contents(livechat_json)[0])
|
||||
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, replay: bool, offset_ms: int = 0):
|
||||
'''
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
livechat_json = None
|
||||
err = None
|
||||
if offset_ms < 0:
|
||||
offset_ms = 0
|
||||
param = util.get_param(continuation, dat=self._dat, replay=replay, offsetms=offset_ms)
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
with httpx.Client(http2=True) as client:
|
||||
try:
|
||||
response = client.post(self._fetch_url, json=param)
|
||||
livechat_json = json.loads(response.text)
|
||||
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,38 @@
|
||||
|
||||
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):
|
||||
if item is None:
|
||||
return
|
||||
if super().full():
|
||||
super().get_nowait()
|
||||
super().put_nowait(item)
|
||||
|
||||
async def get(self):
|
||||
ret = []
|
||||
ret.append(await super().get())
|
||||
while not super().empty():
|
||||
ret.append(super().get_nowait())
|
||||
return ret
|
||||
return ret
|
||||
|
||||
@@ -1,298 +1,354 @@
|
||||
import aiohttp, asyncio
|
||||
import datetime
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import random
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
from asyncio import Queue
|
||||
from concurrent.futures import CancelledError
|
||||
from .buffer import Buffer
|
||||
from ..parser.live import Parser
|
||||
from .. import config
|
||||
from .. import mylogger
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import liveparam
|
||||
from .. import exceptions
|
||||
from .. import util
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
from ..processors.combinator import Combinator
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
MAX_RETRY = 10
|
||||
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例外を発生させる)
|
||||
|
||||
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
|
||||
If True, get only top chat.
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_is_alive : bool
|
||||
チャット取得を停止するためのフラグ
|
||||
Flag to stop getting chat.
|
||||
'''
|
||||
|
||||
_setup_finished = False
|
||||
|
||||
def __init__(self, video_id,
|
||||
processor = DefaultProcessor(),
|
||||
buffer = None,
|
||||
interruptable = True,
|
||||
callback = None,
|
||||
done_callback = None,
|
||||
exception_handler = None,
|
||||
direct_mode = False):
|
||||
self.video_id = video_id
|
||||
self.processor = processor
|
||||
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 = util.extract_video_id(video_id)
|
||||
self.seektime = seektime
|
||||
if isinstance(processor, tuple):
|
||||
self.processor = Combinator(processor)
|
||||
else:
|
||||
self.processor = processor
|
||||
self._buffer = buffer
|
||||
self._callback = callback
|
||||
self._done_callback = done_callback
|
||||
self._exception_handler = exception_handler
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._parser = Parser()
|
||||
self._is_alive = True
|
||||
self._is_replay = force_replay
|
||||
self._parser = Parser(is_replay=self._is_replay)
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
self._first_fetch = True
|
||||
self._fetch_url = config._sml
|
||||
self._topchat_only = topchat_only
|
||||
self._dat = ''
|
||||
self._last_offset_ms = 0
|
||||
self._logger = logger
|
||||
self.exception = None
|
||||
LiveChatAsync._logger = logger
|
||||
|
||||
if exception_handler:
|
||||
self._set_exception_handler(exception_handler)
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT,
|
||||
(lambda a, b: self._keyboard_interrupt()))
|
||||
self._setup()
|
||||
|
||||
if not LiveChatAsync._setup_finished:
|
||||
LiveChatAsync._setup_finished = True
|
||||
if exception_handler == None:
|
||||
self._set_exception_handler(self._handle_exception)
|
||||
else:
|
||||
self._set_exception_handler(exception_handler)
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT,
|
||||
(lambda a, b:asyncio.create_task(
|
||||
LiveChatAsync.shutdown(None,signal.SIGINT,b))
|
||||
))
|
||||
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
# 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(
|
||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
||||
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):
|
||||
"""最初のcontinuationパラメータを取得し、
|
||||
_listenループを開始する
|
||||
"""Fetch first continuation parameter,
|
||||
create and start _listen loop.
|
||||
"""
|
||||
initial_continuation = await self._get_initial_continuation()
|
||||
if initial_continuation is None:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
||||
return
|
||||
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||
await self._listen(initial_continuation)
|
||||
|
||||
async def _get_initial_continuation(self):
|
||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
||||
try:
|
||||
initial_continuation = liveparam.getparam(self.video_id)
|
||||
except ChatParseException as e:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
||||
return
|
||||
except KeyError:
|
||||
logger.debug(f"[{self.video_id}]KeyError:"
|
||||
f"{traceback.format_exc(limit = -1)}")
|
||||
self.terminate()
|
||||
return
|
||||
return initial_continuation
|
||||
|
||||
async def _listen(self, continuation):
|
||||
''' continuationに紐付いたチャットデータを取得し
|
||||
チャットデータを格納、
|
||||
次のcontinuaitonを取得してループする。
|
||||
''' Fetch chat data and store them into buffer,
|
||||
get next continuaiton parameter and loop.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
continuation : str
|
||||
次のチャットデータ取得に必要なパラメータ
|
||||
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):
|
||||
livechat_json = (await
|
||||
self._get_livechat_json(continuation, session, headers)
|
||||
)
|
||||
metadata, chatdata = self._parser.parse( livechat_json )
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
continuation = await self._check_pause(continuation)
|
||||
contents = await 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:
|
||||
await self._callback(
|
||||
self.processor.process([chat_component])
|
||||
)
|
||||
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:
|
||||
logger.info(f"{str(e)}(video_id:\"{self.video_id}\")")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
||||
diff_time = timeout - (time.time() - time_mark)
|
||||
await asyncio.sleep(diff_time)
|
||||
continuation = metadata.get('continuation')
|
||||
self._last_offset_ms = metadata.get('last_offset_ms', 0)
|
||||
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
|
||||
|
||||
async def _get_livechat_json(self, continuation, session, headers):
|
||||
self._logger.debug(f"[{self._video_id}] finished fetching chat.")
|
||||
|
||||
async def _check_pause(self, continuation):
|
||||
if self._pauser.empty():
|
||||
'''pause'''
|
||||
await self._pauser.get()
|
||||
'''resume:
|
||||
prohibit from blocking by putting None into _pauser.
|
||||
'''
|
||||
self._pauser.put_nowait(None)
|
||||
if not self._is_replay:
|
||||
continuation = liveparam.getparam(
|
||||
self._video_id, 3, self._topchat_only)
|
||||
return continuation
|
||||
|
||||
async def _get_contents(self, continuation, 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 & chatdata.
|
||||
'''
|
||||
チャットデータが格納されたjsonデータを取得する。
|
||||
livechat_json = await self._get_livechat_json(continuation, client, replay=self._is_replay, offset_ms=self._last_offset_ms)
|
||||
contents, dat = self._parser.get_contents(livechat_json)
|
||||
if self._dat == '' and dat:
|
||||
self._dat = dat
|
||||
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 = config._smr
|
||||
channelid = await util.get_channelid_async(client, self._video_id)
|
||||
continuation = arcparam.getparam(
|
||||
self._video_id, self.seektime, self._topchat_only, channelid)
|
||||
livechat_json = (await self._get_livechat_json(
|
||||
continuation, client, replay=True, offset_ms=self.seektime * 1000))
|
||||
reload_continuation = self._parser.reload_continuation(
|
||||
self._parser.get_contents(livechat_json)[0])
|
||||
if reload_continuation:
|
||||
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, client, replay: bool, offset_ms: int = 0):
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
# continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =(
|
||||
f"https://www.youtube.com/live_chat/get_live_chat?"
|
||||
f"continuation={continuation}&pbj=1")
|
||||
if offset_ms < 0:
|
||||
offset_ms = 0
|
||||
param = util.get_param(continuation, dat=self._dat, replay=replay, offsetms=offset_ms)
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
async with session.get(url ,headers = headers) as resp:
|
||||
try:
|
||||
text = await resp.text()
|
||||
status_code = resp.status
|
||||
livechat_json = json.loads(text)
|
||||
break
|
||||
except (ClientConnectorError,json.JSONDecodeError) :
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
try:
|
||||
resp = await client.post(self._fetch_url, json=param)
|
||||
livechat_json = resp.json()
|
||||
break
|
||||
except (json.JSONDecodeError, httpx.HTTPError):
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
else:
|
||||
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
|
||||
|
||||
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()
|
||||
data = self.processor.process(items)
|
||||
await callback(data)
|
||||
processed_chat = self.processor.process(items)
|
||||
if isinstance(processed_chat, tuple):
|
||||
await self._callback(*processed_chat)
|
||||
else:
|
||||
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
|
||||
|
||||
def pause(self):
|
||||
if self._callback is None:
|
||||
return
|
||||
if not self._pauser.empty():
|
||||
self._pauser.get_nowait()
|
||||
|
||||
def resume(self):
|
||||
if self._callback is None:
|
||||
return
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
|
||||
def is_alive(self):
|
||||
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:
|
||||
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':1})
|
||||
logger.info(f'終了しました:[{self.video_id}]')
|
||||
|
||||
self._buffer.put_nowait({})
|
||||
self.processor.finalize()
|
||||
|
||||
def _keyboard_interrupt(self):
|
||||
self.exception = exceptions.ChatDataFinished()
|
||||
self.terminate()
|
||||
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def _set_exception_handler(cls, handler):
|
||||
loop = asyncio.get_event_loop()
|
||||
#default handler: cls._handle_exception
|
||||
loop.set_exception_handler(handler)
|
||||
|
||||
@classmethod
|
||||
def _handle_exception(cls, loop, context):
|
||||
#msg = context.get("exception", context["message"])
|
||||
if not isinstance(context["exception"],CancelledError):
|
||||
logger.error(f"Caught exception: {context}")
|
||||
loop= asyncio.get_event_loop()
|
||||
loop.create_task(cls.shutdown(None,None,None))
|
||||
|
||||
@classmethod
|
||||
async def shutdown(cls, event, sig = None, handler=None):
|
||||
logger.debug("シャットダウンしています")
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not
|
||||
asyncio.current_task()]
|
||||
[task.cancel() for task in tasks]
|
||||
|
||||
logger.debug(f"残っているタスクを終了しています")
|
||||
await asyncio.gather(*tasks,return_exceptions=True)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.stop()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import aiohttp, asyncio
|
||||
import datetime
|
||||
import json
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
from concurrent.futures import CancelledError
|
||||
from queue import Queue
|
||||
from .buffer import Buffer
|
||||
from ..parser.replay import Parser
|
||||
from .. import config
|
||||
from .. import mylogger
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
MAX_RETRY = 10
|
||||
headers = config.headers
|
||||
|
||||
class ReplayChatAsync:
|
||||
''' aiohttpを利用してYouTubeのライブ配信のチャットデータを取得する
|
||||
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
動画ID
|
||||
|
||||
processor : ChatProcessor
|
||||
チャットデータを加工するオブジェクト
|
||||
|
||||
buffer : Buffer(maxsize:20[default])
|
||||
チャットデータchat_componentを格納するバッファ。
|
||||
maxsize : 格納できるchat_componentの個数
|
||||
default値20個。1個で約5~10秒分。
|
||||
|
||||
interruptable : bool
|
||||
Ctrl+Cによる処理中断を行うかどうか。
|
||||
|
||||
callback : func
|
||||
_listen()関数から一定間隔で自動的に呼びだす関数。
|
||||
|
||||
done_callback : func
|
||||
listener終了時に呼び出すコールバック。
|
||||
|
||||
direct_mode : bool
|
||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||
Trueの場合、callbackの設定が必須
|
||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_executor : ThreadPoolExecutor
|
||||
チャットデータ取得ループ(_listen)用のスレッド
|
||||
|
||||
_is_alive : bool
|
||||
チャット取得を終了したか
|
||||
'''
|
||||
|
||||
_setup_finished = False
|
||||
|
||||
def __init__(self, video_id,
|
||||
seektime =0,
|
||||
processor = DefaultProcessor(),
|
||||
buffer = Buffer(maxsize = 20),
|
||||
interruptable = True,
|
||||
callback = None,
|
||||
done_callback = None,
|
||||
exception_handler = None,
|
||||
direct_mode = False):
|
||||
self.video_id = video_id
|
||||
self.seektime= seektime
|
||||
self.processor = processor
|
||||
self._buffer = buffer
|
||||
self._callback = callback
|
||||
self._done_callback = done_callback
|
||||
self._exception_handler = exception_handler
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._parser = Parser()
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
self._setup()
|
||||
|
||||
if not ReplayChatAsync._setup_finished:
|
||||
ReplayChatAsync._setup_finished = True
|
||||
if exception_handler == None:
|
||||
self._set_exception_handler(self._handle_exception)
|
||||
else:
|
||||
self._set_exception_handler(exception_handler)
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT,
|
||||
(lambda a, b:asyncio.create_task(
|
||||
ReplayChatAsync.shutdown(None,signal.SIGINT,b))
|
||||
))
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
if self._direct_mode:
|
||||
if self._callback is None:
|
||||
raise IllegalFunctionCall(
|
||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
||||
else:
|
||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||
if self._buffer is None:
|
||||
self._buffer = Buffer(maxsize = 20)
|
||||
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成
|
||||
if self._callback is None:
|
||||
pass
|
||||
else:
|
||||
#callbackを呼ぶループタスクの開始
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(self._callback_loop(self._callback))
|
||||
#_listenループタスクの開始
|
||||
loop = asyncio.get_event_loop()
|
||||
listen_task = loop.create_task(self._startlisten())
|
||||
#add_done_callbackの登録
|
||||
if self._done_callback is None:
|
||||
listen_task.add_done_callback(self.finish)
|
||||
else:
|
||||
listen_task.add_done_callback(self._done_callback)
|
||||
|
||||
async def _startlisten(self):
|
||||
"""最初のcontinuationパラメータを取得し、
|
||||
_listenループのタスクを作成し開始する
|
||||
"""
|
||||
initial_continuation = await self._get_initial_continuation()
|
||||
if initial_continuation is None:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
||||
return
|
||||
await self._listen(initial_continuation)
|
||||
|
||||
async def _get_initial_continuation(self):
|
||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
||||
try:
|
||||
initial_continuation = arcparam.get(self.video_id,self.seektime)
|
||||
except ChatParseException as e:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
||||
return
|
||||
except KeyError:
|
||||
logger.debug(f"[{self.video_id}]KeyError:"
|
||||
f"{traceback.format_exc(limit = -1)}")
|
||||
self.terminate()
|
||||
return
|
||||
return initial_continuation
|
||||
|
||||
async def _listen(self, continuation):
|
||||
''' continuationに紐付いたチャットデータを取得し
|
||||
にチャットデータを格納、
|
||||
次のcontinuaitonを取得してループする
|
||||
|
||||
Parameter
|
||||
---------
|
||||
continuation : str
|
||||
次のチャットデータ取得に必要なパラメータ
|
||||
'''
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while(continuation and self._is_alive):
|
||||
if self._pauser.empty():
|
||||
#pauseが呼ばれて_pauserが空状態のときは一時停止する
|
||||
await self._pauser.get()
|
||||
#resumeが呼ばれて_pauserにitemが入ったら再開する
|
||||
#直後に_pauserにitem(None)を入れてブロックを防ぐ
|
||||
self._pauser.put_nowait(None)
|
||||
livechat_json = (await
|
||||
self._get_livechat_json(continuation, session, headers)
|
||||
)
|
||||
metadata, chatdata = self._parser.parse( livechat_json )
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
chat_component = {
|
||||
"video_id" : self.video_id,
|
||||
"timeout" : timeout,
|
||||
"chatdata" : chatdata
|
||||
}
|
||||
time_mark =time.time()
|
||||
if self._direct_mode:
|
||||
await self._callback(
|
||||
self.processor.process([chat_component])
|
||||
)
|
||||
else:
|
||||
await self._buffer.put(chat_component)
|
||||
diff_time = timeout - (time.time()-time_mark)
|
||||
if diff_time < 0 : diff_time=0
|
||||
await asyncio.sleep(diff_time)
|
||||
continuation = metadata.get('continuation')
|
||||
except ChatParseException as e:
|
||||
logger.error(f"{str(e)}(動画ID:\"{self.video_id}\")")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
||||
|
||||
async def _get_livechat_json(self, continuation, session, headers):
|
||||
'''
|
||||
チャットデータが格納されたjsonデータを取得する。
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =(
|
||||
f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?"
|
||||
f"continuation={continuation}&pbj=1")
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
async with session.get(url ,headers = headers) as resp:
|
||||
try:
|
||||
text = await resp.text()
|
||||
status_code = resp.status
|
||||
livechat_json = json.loads(text)
|
||||
break
|
||||
except (ClientConnectorError,json.JSONDecodeError) :
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"[{self.video_id}]"
|
||||
f"Exceeded retry count. status_code={status_code}")
|
||||
return None
|
||||
return livechat_json
|
||||
|
||||
async def _callback_loop(self,callback):
|
||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||
|
||||
Parameter
|
||||
---------
|
||||
callback : func
|
||||
加工済みのチャットデータを渡す先の関数。
|
||||
"""
|
||||
while self.is_alive():
|
||||
items = await self._buffer.get()
|
||||
data = self.processor.process(items)
|
||||
await callback(data)
|
||||
|
||||
async def get(self):
|
||||
""" bufferからデータを取り出し、processorに投げ、
|
||||
加工済みのチャットデータを返す。
|
||||
|
||||
Returns
|
||||
: Processorによって加工されたチャットデータ
|
||||
"""
|
||||
if self._callback is None:
|
||||
items = await self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
raise IllegalFunctionCall(
|
||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||
|
||||
def pause(self):
|
||||
if not self._pauser.empty():
|
||||
self._pauser.get()
|
||||
|
||||
def resume(self):
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
|
||||
|
||||
def is_alive(self):
|
||||
return self._is_alive
|
||||
|
||||
def finish(self,sender):
|
||||
'''Listener終了時のコールバック'''
|
||||
try:
|
||||
self.terminate()
|
||||
except CancelledError:
|
||||
logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
||||
|
||||
def terminate(self):
|
||||
'''
|
||||
Listenerを終了する。
|
||||
'''
|
||||
self._is_alive = False
|
||||
if self._direct_mode == False:
|
||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
||||
self._buffer.put_nowait({'chatdata':'','timeout':1})
|
||||
logger.info(f'終了しました:[{self.video_id}]')
|
||||
|
||||
@classmethod
|
||||
def _set_exception_handler(cls, handler):
|
||||
loop = asyncio.get_event_loop()
|
||||
#default handler: cls._handle_exception
|
||||
loop.set_exception_handler(handler)
|
||||
|
||||
@classmethod
|
||||
def _handle_exception(cls, loop, context):
|
||||
#msg = context.get("exception", context["message"])
|
||||
if not isinstance(context["exception"],CancelledError):
|
||||
logger.error(f"Caught exception: {context}")
|
||||
loop= asyncio.get_event_loop()
|
||||
loop.create_task(cls.shutdown(None,None,None))
|
||||
|
||||
@classmethod
|
||||
async def shutdown(cls, event, sig = None, handler=None):
|
||||
logger.debug("シャットダウンしています")
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not
|
||||
asyncio.current_task()]
|
||||
[task.cancel() for task in tasks]
|
||||
|
||||
logger.debug(f"残っているタスクを終了しています")
|
||||
await asyncio.gather(*tasks,return_exceptions=True)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.stop()
|
||||
@@ -1,31 +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):
|
||||
if item is None:
|
||||
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,276 +1,339 @@
|
||||
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 .. import mylogger
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import liveparam
|
||||
from .. import exceptions
|
||||
from .. import util
|
||||
from ..paramgen import liveparam, arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
from ..processors.combinator import Combinator
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
MAX_RETRY = 10
|
||||
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
|
||||
force to fetch archived chat data, even if specified video is live.
|
||||
|
||||
topchat_only : bool
|
||||
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,
|
||||
processor = DefaultProcessor(),
|
||||
buffer = Buffer(maxsize = 20),
|
||||
interruptable = True,
|
||||
callback = None,
|
||||
done_callback = None,
|
||||
direct_mode = False
|
||||
):
|
||||
self.video_id = video_id
|
||||
self.processor = processor
|
||||
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 = util.extract_video_id(video_id)
|
||||
self.seektime = seektime
|
||||
if isinstance(processor, tuple):
|
||||
self.processor = Combinator(processor)
|
||||
else:
|
||||
self.processor = processor
|
||||
self._buffer = buffer
|
||||
self._callback = callback
|
||||
self._done_callback = done_callback
|
||||
self._executor = ThreadPoolExecutor(max_workers=2)
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._parser = Parser()
|
||||
self._is_alive = True
|
||||
self._is_replay = force_replay
|
||||
self._parser = Parser(is_replay=self._is_replay)
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
self._first_fetch = True
|
||||
self._fetch_url = config._sml
|
||||
self._topchat_only = topchat_only
|
||||
self._dat = ''
|
||||
self._last_offset_ms = 0
|
||||
self._event = Event()
|
||||
self._logger = logger
|
||||
self.exception = None
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, lambda a, b: self.terminate())
|
||||
self._setup()
|
||||
|
||||
if not LiveChat._setup_finished:
|
||||
LiveChat._setup_finished = True
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, (lambda a, b:
|
||||
(LiveChat.shutdown(None,signal.SIGINT,b))
|
||||
))
|
||||
LiveChat._listeners.append(self)
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
# 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(
|
||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
||||
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):
|
||||
"""最初のcontinuationパラメータを取得し、
|
||||
_listenループのタスクを作成し開始する
|
||||
time.sleep(0.1) # sleep shortly to prohibit skipping fetching data
|
||||
"""Fetch first continuation parameter,
|
||||
create and start _listen loop.
|
||||
"""
|
||||
initial_continuation = self._get_initial_continuation()
|
||||
if initial_continuation is None:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
||||
return
|
||||
initial_continuation = liveparam.getparam(self._video_id, 3)
|
||||
self._listen(initial_continuation)
|
||||
|
||||
def _get_initial_continuation(self):
|
||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
||||
try:
|
||||
initial_continuation = liveparam.getparam(self.video_id)
|
||||
except ChatParseException as e:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
||||
return
|
||||
except KeyError:
|
||||
logger.debug(f"[{self.video_id}]KeyError:"
|
||||
f"{traceback.format_exc(limit = -1)}")
|
||||
self.terminate()
|
||||
return
|
||||
return initial_continuation
|
||||
|
||||
def _listen(self, continuation):
|
||||
''' continuationに紐付いたチャットデータを取得し
|
||||
にチャットデータを格納、
|
||||
次のcontinuaitonを取得してループする
|
||||
''' Fetch chat data and store them into buffer,
|
||||
get next continuaiton parameter and loop.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
continuation : str
|
||||
次のチャットデータ取得に必要なパラメータ
|
||||
parameter for next chat data
|
||||
'''
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
with httpx.Client(http2=True) as client:
|
||||
while(continuation and self._is_alive):
|
||||
livechat_json = (
|
||||
self._get_livechat_json(continuation, session, headers)
|
||||
)
|
||||
metadata, chatdata = self._parser.parse( livechat_json )
|
||||
#チャットデータを含むコンポーネントを組み立ててbufferに投入する
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
continuation = self._check_pause(continuation)
|
||||
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:
|
||||
self._callback(
|
||||
self.processor.process([chat_component])
|
||||
)
|
||||
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)
|
||||
#次のchatを取得するまでsleepする
|
||||
diff_time = timeout - (time.time()-time_mark)
|
||||
if diff_time < 0 : diff_time=0
|
||||
time.sleep(diff_time)
|
||||
#次のチャットデータのcontinuationパラメータを取り出す。
|
||||
continuation = metadata.get('continuation')
|
||||
|
||||
#whileループ先頭に戻る
|
||||
except ChatParseException as e:
|
||||
logger.error(f"{str(e)}(動画ID:\"{self.video_id}\")")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
||||
diff_time = timeout - (time.time() - time_mark)
|
||||
self._event.wait(diff_time if diff_time > 0 else 0)
|
||||
continuation = metadata.get('continuation')
|
||||
self._last_offset_ms = metadata.get('last_offset_ms', 0)
|
||||
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
|
||||
|
||||
def _get_livechat_json(self, continuation, session, headers):
|
||||
self._logger.debug(f"[{self._video_id}] finished fetching chat.")
|
||||
|
||||
def _check_pause(self, continuation):
|
||||
if self._pauser.empty():
|
||||
'''pause'''
|
||||
self._pauser.get()
|
||||
'''resume:
|
||||
prohibit from blocking by putting None into _pauser.
|
||||
'''
|
||||
self._pauser.put_nowait(None)
|
||||
if not self._is_replay:
|
||||
continuation = liveparam.getparam(self._video_id, 3)
|
||||
return continuation
|
||||
|
||||
def _get_contents(self, continuation, 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.
|
||||
'''
|
||||
チャットデータが格納されたjsonデータを取得する。
|
||||
livechat_json = self._get_livechat_json(continuation, client, replay=self._is_replay, offset_ms=self._last_offset_ms)
|
||||
contents, dat = self._parser.get_contents(livechat_json)
|
||||
if self._dat == '' and dat:
|
||||
self._dat = dat
|
||||
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 = config._smr
|
||||
continuation = arcparam.getparam(
|
||||
self._video_id, self.seektime, self._topchat_only, util.get_channelid(client, self._video_id))
|
||||
livechat_json = (self._get_livechat_json(
|
||||
continuation, client, replay=True, offset_ms=self.seektime * 1000))
|
||||
reload_continuation = self._parser.reload_continuation(
|
||||
self._parser.get_contents(livechat_json)[0])
|
||||
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, replay: bool, offset_ms: int = 0):
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
Get json which includes chat data.
|
||||
'''
|
||||
# continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =(
|
||||
f"https://www.youtube.com/live_chat/get_live_chat?"
|
||||
f"continuation={continuation}&pbj=1")
|
||||
if offset_ms < 0:
|
||||
offset_ms = 0
|
||||
param = util.get_param(continuation, dat=self._dat, replay=replay, offsetms=offset_ms)
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
with session.get(url ,headers = headers) as resp:
|
||||
try:
|
||||
text = resp.text
|
||||
status_code = resp.status_code
|
||||
livechat_json = json.loads(text)
|
||||
break
|
||||
except json.JSONDecodeError :
|
||||
time.sleep(1)
|
||||
continue
|
||||
try:
|
||||
response = client.post(self._fetch_url, json=param)
|
||||
livechat_json = response.json()
|
||||
break
|
||||
except (json.JSONDecodeError, httpx.HTTPError):
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
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()
|
||||
data = self.processor.process(items)
|
||||
callback(data)
|
||||
processed_chat = self.processor.process(items)
|
||||
if isinstance(processed_chat, tuple):
|
||||
self._callback(*processed_chat)
|
||||
else:
|
||||
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
|
||||
|
||||
def pause(self):
|
||||
if self._callback is None:
|
||||
return
|
||||
if not self._pauser.empty():
|
||||
self._pauser.get()
|
||||
|
||||
def resume(self):
|
||||
if self._callback is None:
|
||||
return
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
|
||||
def is_alive(self):
|
||||
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:
|
||||
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({'chatdata':'','timeout':1})
|
||||
logger.info(f'終了しました:[{self.video_id}]')
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls, event, sig = None, handler=None):
|
||||
logger.debug("シャットダウンしています")
|
||||
for t in LiveChat._listeners:
|
||||
t._is_alive = False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import requests
|
||||
import datetime
|
||||
import json
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
||||
from queue import Queue
|
||||
from .buffer import Buffer
|
||||
from ..parser.replay import Parser
|
||||
from .. import config
|
||||
from .. import mylogger
|
||||
from ..exceptions import ChatParseException,IllegalFunctionCall
|
||||
from ..paramgen import arcparam
|
||||
from ..processors.default.processor import DefaultProcessor
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
MAX_RETRY = 10
|
||||
headers = config.headers
|
||||
|
||||
|
||||
|
||||
class ReplayChat:
|
||||
''' スレッドプールを利用してYouTubeのライブ配信のチャットデータを取得する
|
||||
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
動画ID
|
||||
|
||||
processor : ChatProcessor
|
||||
チャットデータを加工するオブジェクト
|
||||
|
||||
buffer : Buffer(maxsize:20[default])
|
||||
チャットデータchat_componentを格納するバッファ。
|
||||
maxsize : 格納できるchat_componentの個数
|
||||
default値20個。1個で約5~10秒分。
|
||||
|
||||
interruptable : bool
|
||||
Ctrl+Cによる処理中断を行うかどうか。
|
||||
|
||||
callback : func
|
||||
_listen()関数から一定間隔で自動的に呼びだす関数。
|
||||
|
||||
done_callback : func
|
||||
listener終了時に呼び出すコールバック。
|
||||
|
||||
direct_mode : bool
|
||||
Trueの場合、bufferを使わずにcallbackを呼ぶ。
|
||||
Trueの場合、callbackの設定が必須
|
||||
(設定していない場合IllegalFunctionCall例外を発生させる)
|
||||
|
||||
Attributes
|
||||
---------
|
||||
_executor : ThreadPoolExecutor
|
||||
チャットデータ取得ループ(_listen)用のスレッド
|
||||
|
||||
_is_alive : bool
|
||||
チャット取得を終了したか
|
||||
'''
|
||||
|
||||
_setup_finished = False
|
||||
#チャット監視中のListenerのリスト
|
||||
_listeners= []
|
||||
def __init__(self, video_id,
|
||||
seektime =0,
|
||||
processor = DefaultProcessor(),
|
||||
buffer = Buffer(maxsize = 20),
|
||||
interruptable = True,
|
||||
callback = None,
|
||||
done_callback = None,
|
||||
direct_mode = False
|
||||
):
|
||||
self.video_id = video_id
|
||||
self.seektime= seektime
|
||||
self.processor = processor
|
||||
self._buffer = buffer
|
||||
self._callback = callback
|
||||
self._done_callback = done_callback
|
||||
self._executor = ThreadPoolExecutor(max_workers=2)
|
||||
self._direct_mode = direct_mode
|
||||
self._is_alive = True
|
||||
self._parser = Parser()
|
||||
self._pauser = Queue()
|
||||
self._pauser.put_nowait(None)
|
||||
|
||||
self._setup()
|
||||
if not ReplayChat._setup_finished:
|
||||
ReplayChat._setup_finished = True
|
||||
if interruptable:
|
||||
signal.signal(signal.SIGINT, (lambda a, b:
|
||||
(ReplayChat.shutdown(None,signal.SIGINT,b))
|
||||
))
|
||||
ReplayChat._listeners.append(self)
|
||||
|
||||
def _setup(self):
|
||||
#direct modeがTrueでcallback未設定の場合例外発生。
|
||||
if self._direct_mode:
|
||||
if self._callback is None:
|
||||
raise IllegalFunctionCall(
|
||||
"direct_mode=Trueの場合callbackの設定が必須です。")
|
||||
else:
|
||||
#direct modeがFalseでbufferが未設定ならばデフォルトのbufferを作成
|
||||
if self._buffer is None:
|
||||
self._buffer = Buffer(maxsize = 20)
|
||||
#callbackが指定されている場合はcallbackを呼ぶループタスクを作成
|
||||
if self._callback is None:
|
||||
pass
|
||||
else:
|
||||
#callbackを呼ぶループタスクの開始
|
||||
self._executor.submit(self._callback_loop,self._callback)
|
||||
#_listenループタスクの開始
|
||||
listen_task = self._executor.submit(self._startlisten)
|
||||
#add_done_callbackの登録
|
||||
if self._done_callback is None:
|
||||
listen_task.add_done_callback(self.finish)
|
||||
else:
|
||||
listen_task.add_done_callback(self._done_callback)
|
||||
|
||||
def _startlisten(self):
|
||||
"""最初のcontinuationパラメータを取得し、
|
||||
_listenループのタスクを作成し開始する
|
||||
"""
|
||||
initial_continuation = self._get_initial_continuation()
|
||||
if initial_continuation is None:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]No initial continuation.")
|
||||
return
|
||||
self._listen(initial_continuation)
|
||||
|
||||
def _get_initial_continuation(self):
|
||||
''' チャットデータ取得に必要な最初のcontinuationを取得する。'''
|
||||
try:
|
||||
initial_continuation = arcparam.get(self.video_id,self.seektime)
|
||||
except ChatParseException as e:
|
||||
self.terminate()
|
||||
logger.debug(f"[{self.video_id}]Error:{str(e)}")
|
||||
return
|
||||
except KeyError:
|
||||
logger.debug(f"[{self.video_id}]KeyError:"
|
||||
f"{traceback.format_exc(limit = -1)}")
|
||||
self.terminate()
|
||||
return
|
||||
return initial_continuation
|
||||
|
||||
def _listen(self, continuation):
|
||||
''' continuationに紐付いたチャットデータを取得し
|
||||
にチャットデータを格納、
|
||||
次のcontinuaitonを取得してループする
|
||||
|
||||
Parameter
|
||||
---------
|
||||
continuation : str
|
||||
次のチャットデータ取得に必要なパラメータ
|
||||
'''
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
while(continuation and self._is_alive):
|
||||
if self._pauser.empty():
|
||||
#pauseが呼ばれて_pauserが空状態のときは一時停止する
|
||||
self._pauser.get()
|
||||
#resumeが呼ばれて_pauserにitemが入ったら再開する
|
||||
#直後に_pauserにitem(None)を入れてブロックを防ぐ
|
||||
self._pauser.put_nowait(None)
|
||||
livechat_json = (
|
||||
self._get_livechat_json(continuation, session, headers)
|
||||
)
|
||||
metadata, chatdata = self._parser.parse( livechat_json )
|
||||
#チャットデータを含むコンポーネントを組み立ててbufferに投入する
|
||||
timeout = metadata['timeoutMs']/1000
|
||||
chat_component = {
|
||||
"video_id" : self.video_id,
|
||||
"timeout" : timeout,
|
||||
"chatdata" : chatdata
|
||||
}
|
||||
time_mark =time.time()
|
||||
if self._direct_mode:
|
||||
self._callback(
|
||||
self.processor.process([chat_component])
|
||||
)
|
||||
else:
|
||||
self._buffer.put(chat_component)
|
||||
diff_time = timeout - (time.time()-time_mark)
|
||||
if diff_time < 0 : diff_time=0
|
||||
time.sleep(diff_time)
|
||||
continuation = metadata.get('continuation')
|
||||
except ChatParseException as e:
|
||||
logger.error(f"{str(e)}(動画ID:\"{self.video_id}\")")
|
||||
return
|
||||
except (TypeError , json.JSONDecodeError) :
|
||||
logger.error(f"{traceback.format_exc(limit = -1)}")
|
||||
return
|
||||
|
||||
logger.debug(f"[{self.video_id}]チャット取得を終了しました。")
|
||||
|
||||
def _get_livechat_json(self, continuation, session, headers):
|
||||
'''
|
||||
チャットデータが格納されたjsonデータを取得する。
|
||||
'''
|
||||
continuation = urllib.parse.quote(continuation)
|
||||
livechat_json = None
|
||||
status_code = 0
|
||||
url =(
|
||||
f"https://www.youtube.com/live_chat_replay/get_live_chat_replay?"
|
||||
f"continuation={continuation}&pbj=1")
|
||||
for _ in range(MAX_RETRY + 1):
|
||||
with session.get(url ,headers = headers) as resp:
|
||||
try:
|
||||
text = resp.text
|
||||
status_code = resp.status_code
|
||||
livechat_json = json.loads(text)
|
||||
break
|
||||
except json.JSONDecodeError :
|
||||
time.sleep(1)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"[{self.video_id}]"
|
||||
f"Exceeded retry count. status_code={status_code}")
|
||||
return None
|
||||
return livechat_json
|
||||
|
||||
def _callback_loop(self,callback):
|
||||
""" コンストラクタでcallbackを指定している場合、バックグラウンドで
|
||||
callbackに指定された関数に一定間隔でチャットデータを投げる。
|
||||
|
||||
Parameter
|
||||
---------
|
||||
callback : func
|
||||
加工済みのチャットデータを渡す先の関数。
|
||||
"""
|
||||
while self.is_alive():
|
||||
items = self._buffer.get()
|
||||
data = self.processor.process(items)
|
||||
callback(data)
|
||||
|
||||
def get(self):
|
||||
""" bufferからデータを取り出し、processorに投げ、
|
||||
加工済みのチャットデータを返す。
|
||||
|
||||
Returns
|
||||
: Processorによって加工されたチャットデータ
|
||||
"""
|
||||
if self._callback is None:
|
||||
items = self._buffer.get()
|
||||
return self.processor.process(items)
|
||||
raise IllegalFunctionCall(
|
||||
"既にcallbackを登録済みのため、get()は実行できません。")
|
||||
|
||||
def pause(self):
|
||||
if not self._pauser.empty():
|
||||
self._pauser.get()
|
||||
|
||||
def resume(self):
|
||||
if self._pauser.empty():
|
||||
self._pauser.put_nowait(None)
|
||||
|
||||
|
||||
def is_alive(self):
|
||||
return self._is_alive
|
||||
|
||||
def finish(self,sender):
|
||||
'''Listener終了時のコールバック'''
|
||||
try:
|
||||
self.terminate()
|
||||
except CancelledError:
|
||||
logger.debug(f'[{self.video_id}]cancelled:{sender}')
|
||||
|
||||
def terminate(self):
|
||||
'''
|
||||
Listenerを終了する。
|
||||
'''
|
||||
self._is_alive = False
|
||||
if self._direct_mode == False:
|
||||
#bufferにダミーオブジェクトを入れてis_alive()を判定させる
|
||||
self._buffer.put({'chatdata':'','timeout':1})
|
||||
logger.info(f'終了しました:[{self.video_id}]')
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls, event, sig = None, handler=None):
|
||||
logger.debug("シャットダウンしています")
|
||||
for t in ReplayChat._listeners:
|
||||
t._is_alive = False
|
||||
@@ -1,43 +1,81 @@
|
||||
class ChatParseException(Exception):
|
||||
'''
|
||||
チャットデータをパースするライブラリが投げる例外の基底クラス
|
||||
Base exception thrown by the parser
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoYtinitialdataException(ChatParseException):
|
||||
'''
|
||||
配信ページ内にチャットデータurlが見つからないときに投げる例外
|
||||
'''
|
||||
pass
|
||||
|
||||
class ResponseContextError(ChatParseException):
|
||||
'''
|
||||
配信ページでチャットデータ無効の時に投げる例外
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoLivechatRendererException(ChatParseException):
|
||||
'''
|
||||
チャットデータのJSON中にlivechatRendererがない時に投げる例外
|
||||
Thrown when chat data is invalid.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class NoContentsException(ChatParseException):
|
||||
class NoContents(ChatParseException):
|
||||
'''
|
||||
チャットデータのJSON中にContinuationContentsがない時に投げる例外
|
||||
Thrown when ContinuationContents is missing in JSON.
|
||||
'''
|
||||
pass
|
||||
|
||||
class NoContinuationsException(ChatParseException):
|
||||
|
||||
class NoContinuation(ChatParseException):
|
||||
'''
|
||||
チャットデータのContinuationContents中にcontinuationがない時に投げる例外
|
||||
Thrown when continuation is missing in ContinuationContents.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class IllegalFunctionCall(Exception):
|
||||
'''
|
||||
set_callback()を実行済みにもかかわらず
|
||||
get()を呼び出した場合の例外
|
||||
Thrown when get() is called even though
|
||||
set_callback() has been executed.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class InvalidVideoIdException(Exception):
|
||||
'''
|
||||
Thrown when the video_id is not exist (VideoInfo).
|
||||
'''
|
||||
def __init__(self, doc):
|
||||
self.msg = "InvalidVideoIdException"
|
||||
self.doc = doc
|
||||
|
||||
|
||||
class UnknownConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RetryExceedMaxCount(Exception):
|
||||
'''
|
||||
Thrown when the number of retries exceeds the maximum value.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class ChatDataFinished(ChatParseException):
|
||||
pass
|
||||
|
||||
|
||||
class ReceivedUnknownContinuation(ChatParseException):
|
||||
pass
|
||||
|
||||
|
||||
class FailedExtractContinuation(ChatDataFinished):
|
||||
pass
|
||||
|
||||
|
||||
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,31 +0,0 @@
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
def get_logger(modname,mode=logging.DEBUG):
|
||||
logger = logging.getLogger(modname)
|
||||
if mode == None:
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return logger
|
||||
logger.setLevel(mode)
|
||||
#create handler1 for showing info
|
||||
handler1 = logging.StreamHandler()
|
||||
my_formatter = MyFormatter()
|
||||
handler1.setFormatter(my_formatter)
|
||||
|
||||
handler1.setLevel(mode)
|
||||
logger.addHandler(handler1)
|
||||
#create handler2 for recording log file
|
||||
if mode <= logging.DEBUG:
|
||||
handler2 = logging.FileHandler(filename="log.txt")
|
||||
handler2.setLevel(logging.ERROR)
|
||||
handler2.setFormatter(my_formatter)
|
||||
|
||||
|
||||
logger.addHandler(handler2)
|
||||
return logger
|
||||
|
||||
class MyFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
s =(datetime.datetime.fromtimestamp(record.created)).strftime("%m-%d %H:%M:%S")+'| '+ (record.module).ljust(15)+(' { '+record.funcName).ljust(20) +":"+str(record.lineno).rjust(4)+'} - '+record.getMessage()
|
||||
return s
|
||||
@@ -1,120 +1,44 @@
|
||||
from . import enc
|
||||
from base64 import urlsafe_b64encode as b64enc
|
||||
from functools import reduce
|
||||
import calendar, datetime, pytz
|
||||
import math
|
||||
import random
|
||||
import urllib.parse
|
||||
from urllib.parse import quote
|
||||
|
||||
'''
|
||||
Generate continuation parameter of youtube replay chat.
|
||||
|
||||
Author: taizan-hokuto (2019) @taizan205
|
||||
def _header(video_id, channel_id) -> str:
|
||||
S1_3 = enc.rs(1, video_id)
|
||||
S1_5 = enc.rs(1, channel_id) + enc.rs(2, video_id)
|
||||
S1 = enc.rs(3, S1_3) + enc.rs(5, S1_5)
|
||||
S3 = enc.rs(48687757, enc.rs(1, video_id))
|
||||
header_replay = enc.rs(1, S1) + enc.rs(3, S3) + enc.nm(4, 1)
|
||||
return b64enc(header_replay)
|
||||
|
||||
ver 0.0.1 2019.10.05
|
||||
'''
|
||||
|
||||
def _gen_vid(video_id):
|
||||
"""generate video_id parameter.
|
||||
def _build(video_id, seektime, topchat_only, channel_id) -> str:
|
||||
chattype = 4 if topchat_only else 1
|
||||
if seektime < 0:
|
||||
seektime = 0
|
||||
timestamp = int(seektime * 1000000)
|
||||
header = enc.rs(3, _header(video_id, channel_id))
|
||||
timestamp = enc.nm(5, timestamp)
|
||||
s6 = enc.nm(6, 0)
|
||||
s7 = enc.nm(7, 0)
|
||||
s8 = enc.nm(8, 0)
|
||||
s9 = enc.nm(9, 4)
|
||||
s10 = enc.rs(10, enc.nm(4, 0))
|
||||
chattype = enc.rs(14, enc.nm(1, 4))
|
||||
s15 = enc.nm(15, 0)
|
||||
entity = b''.join((header, timestamp, s6, s7, s8, s9, s10, chattype, s15))
|
||||
continuation = enc.rs(156074452, entity)
|
||||
return quote(b64enc(continuation).decode())
|
||||
|
||||
|
||||
def getparam(video_id, seektime=0, topchat_only=False, channel_id='') -> str:
|
||||
'''
|
||||
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 _nval(val):
|
||||
"""convert value to byte array"""
|
||||
if val<0: raise ValueError
|
||||
buf = b''
|
||||
while val >> 7:
|
||||
m = val & 0xFF | 0x80
|
||||
buf += m.to_bytes(1,'big')
|
||||
val >>= 7
|
||||
buf += val.to_bytes(1,'big')
|
||||
return buf
|
||||
|
||||
|
||||
def _tzparity(video_id,times):
|
||||
t=0
|
||||
for i,s in enumerate(video_id):
|
||||
ss = ord(s)
|
||||
if(ss % 2 == 0):
|
||||
t += ss*(12-i)
|
||||
else:
|
||||
t ^= ss*i
|
||||
|
||||
return ((times^t) % 2).to_bytes(1,'big')
|
||||
|
||||
|
||||
def get(video_id, seektime = 0, topchatonly = False):
|
||||
switch_01 = b'\x04' if topchatonly else b'\x01'
|
||||
|
||||
|
||||
if seektime < 0:
|
||||
raise ValueError('seektime is 0 or positive number.')
|
||||
if seektime == 0:
|
||||
times =_nval(1)
|
||||
switch = b'\x04'
|
||||
else:
|
||||
times =_nval(int(seektime*1000000))
|
||||
switch = b'\x03'
|
||||
parity = _tzparity(video_id, seektime)
|
||||
|
||||
header_magic= b'\xA2\x9D\xB0\xD3\x04'
|
||||
sep_0 = b'\x1A'
|
||||
vid = _gen_vid(video_id)
|
||||
time_tag = b'\x28'
|
||||
timestamp1 = times
|
||||
sep_1 = b'\x30\x00\x38\x00\x40\x00\x48'
|
||||
sep_2 = b'\x52\x1C\x08\x00\x10\x00\x18\x00\x20\x00'
|
||||
chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D\x40'
|
||||
sep_3 = b'\x00\x58\x03\x60'
|
||||
sep_4 = b'\x68'+parity+b'\x72\x04\x08'
|
||||
sep_5 = b'\x10'+parity+b'\x78\x00'
|
||||
body = [
|
||||
sep_0,
|
||||
_nval(len(vid)),
|
||||
vid,
|
||||
time_tag,
|
||||
timestamp1,
|
||||
sep_1,
|
||||
switch,
|
||||
sep_2,
|
||||
chkstr,
|
||||
sep_3,
|
||||
switch_01,
|
||||
sep_4,
|
||||
switch_01,
|
||||
sep_5
|
||||
]
|
||||
|
||||
body = reduce(lambda x, y: x+y, body)
|
||||
|
||||
return urllib.parse.quote(
|
||||
b64enc( header_magic +
|
||||
_nval(len(body)) +
|
||||
body
|
||||
).decode()
|
||||
)
|
||||
|
||||
|
||||
|
||||
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, channel_id)
|
||||
|
||||
24
pytchat/paramgen/enc.py
Normal file
24
pytchat/paramgen/enc.py
Normal file
@@ -0,0 +1,24 @@
|
||||
def vn(val):
|
||||
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 tp(a, b, ary):
|
||||
return vn((b << 3) | a) + ary
|
||||
|
||||
|
||||
def rs(a, ary):
|
||||
if isinstance(ary, str):
|
||||
ary = ary.encode()
|
||||
return tp(2, a, vn(len(ary)) + ary)
|
||||
|
||||
|
||||
def nm(a, ary):
|
||||
return tp(0, a, vn(ary))
|
||||
@@ -1,166 +1,65 @@
|
||||
from base64 import urlsafe_b64encode as b64enc
|
||||
from functools import reduce
|
||||
import calendar, datetime, pytz
|
||||
import random
|
||||
import urllib.parse
|
||||
import time
|
||||
from . import enc
|
||||
from base64 import urlsafe_b64encode as b64enc
|
||||
from urllib.parse import quote
|
||||
|
||||
'''
|
||||
Generate continuation parameter of youtube live chat.
|
||||
|
||||
Author: taizan-hokuto (2019) @taizan205
|
||||
def _header(video_id) -> str:
|
||||
return b64enc(enc.rs(1, enc.rs(1, enc.rs(1, video_id))) + enc.nm(4, 1))
|
||||
|
||||
ver 0.0.1 2019.10.05
|
||||
'''
|
||||
def _gen_vid(video_id):
|
||||
"""generate video_id parameter.
|
||||
Parameter
|
||||
---------
|
||||
video_id : str
|
||||
|
||||
Return
|
||||
---------
|
||||
byte[] : base64 encoded video_id parameter.
|
||||
"""
|
||||
header_magic = b'\x0A\x0F\x0A\x0D\x0A'
|
||||
header_id = video_id.encode()
|
||||
header_sep_1 = b'\x1A'
|
||||
header_sep_2 = b'\x43\xAA\xB9\xC1\xBD\x01\x3D\x0A'
|
||||
header_suburl = ('https://www.youtube.com/live_chat?v='
|
||||
f'{video_id}&is_popout=1').encode()
|
||||
header_terminator = b'\x20\x02'
|
||||
def _build(video_id, ts1, ts2, ts3, ts4, ts5, topchat_only) -> str:
|
||||
chattype = 4 if topchat_only else 1
|
||||
|
||||
item = [
|
||||
header_magic,
|
||||
_nval(len(header_id)),
|
||||
header_id,
|
||||
header_sep_1,
|
||||
header_sep_2,
|
||||
_nval(len(header_suburl)),
|
||||
header_suburl,
|
||||
header_terminator
|
||||
]
|
||||
b1 = enc.nm(1, 0)
|
||||
b2 = enc.nm(2, 0)
|
||||
b3 = enc.nm(3, 0)
|
||||
b4 = enc.nm(4, 0)
|
||||
b7 = enc.rs(7, '')
|
||||
b8 = enc.nm(8, 0)
|
||||
b9 = enc.rs(9, '')
|
||||
timestamp2 = enc.nm(10, ts2)
|
||||
b11 = enc.nm(11, 3)
|
||||
b15 = enc.nm(15, 0)
|
||||
|
||||
return urllib.parse.quote(
|
||||
b64enc(reduce(lambda x, y: x+y, item)).decode()
|
||||
).encode()
|
||||
header = enc.rs(3, _header(video_id))
|
||||
timestamp1 = enc.nm(5, ts1)
|
||||
s6 = enc.nm(6, 0)
|
||||
s7 = enc.nm(7, 0)
|
||||
s8 = enc.nm(8, 1)
|
||||
body = enc.rs(9, b''.join(
|
||||
(b1, b2, b3, b4, b7, b8, b9, timestamp2, b11, b15)))
|
||||
timestamp3 = enc.nm(10, ts3)
|
||||
timestamp4 = enc.nm(11, ts4)
|
||||
s13 = enc.nm(13, chattype)
|
||||
chattype = enc.rs(16, enc.nm(1, chattype))
|
||||
s17 = enc.nm(17, 0)
|
||||
str19 = enc.rs(19, enc.nm(1, 0))
|
||||
timestamp5 = enc.nm(20, ts5)
|
||||
entity = b''.join((header, timestamp1, s6, s7, s8, body, timestamp3,
|
||||
timestamp4, s13, chattype, s17, str19, timestamp5))
|
||||
continuation = enc.rs(119693434, entity)
|
||||
return quote(b64enc(continuation).decode())
|
||||
|
||||
def _tzparity(video_id,times):
|
||||
t=0
|
||||
for i,s in enumerate(video_id):
|
||||
ss = ord(s)
|
||||
if(ss % 2 == 0):
|
||||
t += ss*(12-i)
|
||||
else:
|
||||
t ^= ss*i
|
||||
|
||||
return ((times^t) % 2).to_bytes(1,'big')
|
||||
|
||||
def _nval(val):
|
||||
"""convert value to byte array"""
|
||||
if val<0: raise ValueError
|
||||
buf = b''
|
||||
while val >> 7:
|
||||
m = val & 0xFF | 0x80
|
||||
buf += m.to_bytes(1,'big')
|
||||
val >>= 7
|
||||
buf += val.to_bytes(1,'big')
|
||||
return buf
|
||||
|
||||
def _build(video_id, _ts1, _ts2, _ts3, _ts4, _ts5, topchatonly = False):
|
||||
#_short_type2
|
||||
switch_01 = b'\x04' if topchatonly else b'\x01'
|
||||
parity = _tzparity(video_id, _ts1^_ts2^_ts3^_ts4^_ts5)
|
||||
|
||||
header_magic= b'\xD2\x87\xCC\xC8\x03'
|
||||
sep_0 = b'\x1A'
|
||||
vid = _gen_vid(video_id)
|
||||
time_tag = b'\x28'
|
||||
timestamp1 = _nval(_ts1)
|
||||
sep_1 = b'\x30\x00\x38\x00\x40\x02\x4A'
|
||||
un_len = b'\x2B'
|
||||
sep_2 = b'\x08'+parity+b'\x10\x00\x18\x00\x20\x00'
|
||||
chkstr = b'\x2A\x0E\x73\x74\x61\x74\x69\x63\x63\x68\x65\x63\x6B\x73\x75\x6D'
|
||||
sep_3 = b'\x3A\x00\x40\x00\x4A'
|
||||
sep_4_len = b'\x02'
|
||||
sep_4 = b'\x08\x01'
|
||||
ts_2_start = b'\x50'
|
||||
timestamp2 = _nval(_ts2)
|
||||
ts_2_end = b'\x58'
|
||||
sep_5 = b'\x03'
|
||||
ts_3_start = b'\x50'
|
||||
timestamp3 = _nval(_ts3)
|
||||
ts_3_end = b'\x58'
|
||||
timestamp4 = _nval(_ts4)
|
||||
sep_6 = b'\x68'
|
||||
#switch
|
||||
sep_7 = b'\x82\x01\x04\x08'
|
||||
#switch
|
||||
sep_8 = b'\x10\x00'
|
||||
sep_9 = b'\x88\x01\x00\xA0\x01'
|
||||
timestamp5 = _nval(_ts5)
|
||||
|
||||
body = [
|
||||
sep_0,
|
||||
_nval(len(vid)),
|
||||
vid,
|
||||
time_tag,
|
||||
timestamp1,
|
||||
sep_1,
|
||||
un_len,
|
||||
sep_2,
|
||||
chkstr,
|
||||
sep_3,
|
||||
sep_4_len,
|
||||
sep_4,
|
||||
ts_2_start,
|
||||
timestamp2,
|
||||
ts_2_end,
|
||||
sep_5,
|
||||
ts_3_start,
|
||||
timestamp3,
|
||||
ts_3_end,
|
||||
timestamp4,
|
||||
sep_6,
|
||||
switch_01,#
|
||||
sep_7,
|
||||
switch_01,#
|
||||
sep_8,
|
||||
sep_9,
|
||||
timestamp5
|
||||
]
|
||||
|
||||
body = reduce(lambda x, y: x+y, body)
|
||||
|
||||
return urllib.parse.quote(
|
||||
b64enc( header_magic +
|
||||
_nval(len(body)) +
|
||||
body
|
||||
).decode()
|
||||
)
|
||||
|
||||
|
||||
def _times(past_sec):
|
||||
|
||||
def unixts_now():
|
||||
now = datetime.datetime.now(pytz.utc)
|
||||
return calendar.timegm(now.utctimetuple())
|
||||
|
||||
n = unixts_now()
|
||||
|
||||
_ts1= n - random.uniform(0,1*3)
|
||||
_ts2= n - random.uniform(0.01,0.99)
|
||||
_ts3= n - 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]))
|
||||
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]))
|
||||
|
||||
|
||||
def getparam(video_id,past_sec = 60):
|
||||
def getparam(video_id, past_sec=0, topchat_only=False) -> str:
|
||||
'''
|
||||
Parameter
|
||||
---------
|
||||
past_sec : int
|
||||
seconds to load past chat data
|
||||
topchat_only : bool
|
||||
if True, fetch only 'top chat'
|
||||
'''
|
||||
return _build(video_id,*_times(past_sec))
|
||||
|
||||
return _build(video_id, *_times(past_sec), topchat_only)
|
||||
@@ -1,39 +1,122 @@
|
||||
import json
|
||||
from .. import config
|
||||
from .. import mylogger
|
||||
from .. exceptions import (
|
||||
ResponseContextError,
|
||||
NoContentsException,
|
||||
NoContinuationsException )
|
||||
"""
|
||||
pytchat.parser.live
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
Parser of live chat JSON.
|
||||
"""
|
||||
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
from .. import exceptions
|
||||
|
||||
|
||||
class Parser:
|
||||
def parse(self, jsn):
|
||||
if jsn is None:
|
||||
return {'timeoutMs':0,'continuation':None},[]
|
||||
if jsn['response']['responseContext'].get('errors'):
|
||||
raise ResponseContextError('動画に接続できません。'
|
||||
'動画IDが間違っているか、動画が削除/非公開の可能性があります。')
|
||||
contents=jsn['response'].get('continuationContents')
|
||||
#配信が終了した場合、もしくはチャットデータが取得できない場合
|
||||
'''
|
||||
Parser of chat json.
|
||||
|
||||
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:
|
||||
self.raise_exception(exceptions.IllegalFunctionCall('Called with none JSON object.'))
|
||||
if jsn.get("responseContext", {}).get("errors"):
|
||||
raise exceptions.ResponseContextError(
|
||||
'The video_id would be wrong, or video is deleted or private.')
|
||||
contents = jsn.get('continuationContents')
|
||||
visitor_data = jsn.get("responseContext", {}).get("visitorData")
|
||||
return contents, visitor_data
|
||||
|
||||
def parse(self, contents):
|
||||
"""
|
||||
Parameter
|
||||
----------
|
||||
+ contents : dict
|
||||
+ JSON of chat data from YouTube.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple:
|
||||
+ metadata : dict
|
||||
+ timeout
|
||||
+ video_id
|
||||
+ continuation
|
||||
+ chatdata : List[dict]
|
||||
"""
|
||||
|
||||
if contents is None:
|
||||
raise NoContentsException('チャットデータを取得できませんでした。')
|
||||
'''Broadcasting end or cannot fetch chat stream'''
|
||||
self.raise_exception(exceptions.NoContents('Chat data stream is empty.'))
|
||||
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont is None:
|
||||
raise NoContinuationsException('Continuationがありません。')
|
||||
metadata = (cont.get('invalidationContinuationData') or
|
||||
cont.get('timedContinuationData') or
|
||||
cont.get('reloadContinuationData')
|
||||
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"):
|
||||
self.raise_exception(exceptions.ChatDataFinished('Finished chat data'))
|
||||
unknown = list(cont.keys())[0]
|
||||
if unknown:
|
||||
logger.error(f"Received unknown continuation type:{unknown}")
|
||||
metadata = cont.get(unknown)
|
||||
metadata.setdefault('timeoutMs', 10000)
|
||||
chatdata = contents['liveChatContinuation'].get('actions')
|
||||
self.raise_exception(exceptions.ReceivedUnknownContinuation(
|
||||
f"Received unknown continuation type:{unknown}"))
|
||||
else:
|
||||
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.
|
||||
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.
|
||||
return None
|
||||
# chat data do not exist, get playerSeekContinuationData.
|
||||
init_cont = cont.get("playerSeekContinuationData")
|
||||
if init_cont:
|
||||
return init_cont.get("continuation")
|
||||
self.raise_exception(exceptions.ChatDataFinished('Finished chat data'))
|
||||
|
||||
def _create_data(self, metadata, contents):
|
||||
actions = contents['liveChatContinuation'].get('actions')
|
||||
if self.is_replay:
|
||||
last_offset_ms = self._get_lastoffset(actions)
|
||||
metadata.setdefault("timeoutMs", 5000)
|
||||
metadata.setdefault("last_offset_ms", last_offset_ms)
|
||||
"""Archived chat has different structures than live chat,
|
||||
so make it the same format."""
|
||||
chatdata = [action["replayChatItemAction"]["actions"][0]
|
||||
for action in actions]
|
||||
else:
|
||||
metadata.setdefault('timeoutMs', 5000)
|
||||
chatdata = actions
|
||||
return metadata, chatdata
|
||||
|
||||
def _get_lastoffset(self, actions: list):
|
||||
if actions:
|
||||
return int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||
return 0
|
||||
|
||||
def raise_exception(self, exception):
|
||||
if self.exception_holder is None:
|
||||
raise exception
|
||||
self.exception_holder = exception
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import json
|
||||
from .. import config
|
||||
from .. import mylogger
|
||||
from .. exceptions import (
|
||||
ResponseContextError,
|
||||
NoContentsException,
|
||||
NoContinuationsException )
|
||||
|
||||
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
|
||||
|
||||
class Parser:
|
||||
def parse(self, jsn):
|
||||
if jsn is None:
|
||||
return {'timeoutMs':0,'continuation':None},[]
|
||||
if jsn['response']['responseContext'].get('errors'):
|
||||
raise ResponseContextError('動画に接続できません。'
|
||||
'動画IDが間違っているか、動画が削除/非公開の可能性があります。')
|
||||
contents=jsn['response'].get('continuationContents')
|
||||
#配信が終了した場合、もしくはチャットデータが取得できない場合
|
||||
if contents is None:
|
||||
raise NoContentsException('チャットデータを取得できませんでした。')
|
||||
|
||||
cont = contents['liveChatContinuation']['continuations'][0]
|
||||
if cont is None:
|
||||
raise NoContinuationsException('Continuationがありません。')
|
||||
metadata = cont.get('liveChatReplayContinuationData')
|
||||
if metadata is None:
|
||||
unknown = list(cont.keys())[0]
|
||||
if unknown:
|
||||
metadata = cont.get(unknown)
|
||||
|
||||
actions = contents['liveChatContinuation'].get('actions')
|
||||
if actions is None:
|
||||
raise NoContentsException('チャットデータを取得できませんでした。')
|
||||
interval = self.get_interval(actions)
|
||||
metadata.setdefault("timeoutMs",interval)
|
||||
chatdata = []
|
||||
for action in actions:
|
||||
chatdata.append(action["replayChatItemAction"]["actions"][0])
|
||||
return metadata, chatdata
|
||||
|
||||
def get_interval(self, actions: list):
|
||||
if actions is None:
|
||||
return 0
|
||||
start = int(actions[0]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||
last = int(actions[-1]["replayChatItemAction"]["videoOffsetTimeMsec"])
|
||||
return (last - start)
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
class ChatProcessor:
|
||||
'''
|
||||
Listenerからチャットデータ(actions)を受け取り
|
||||
チャットデータを加工するクラスの抽象クラス
|
||||
Abstract class that processes chat data.
|
||||
Receive chat data (actions) from Listener.
|
||||
'''
|
||||
|
||||
def process(self, chat_components: list):
|
||||
'''
|
||||
チャットデータの加工を表すインターフェース。
|
||||
LiveChatオブジェクトから呼び出される。
|
||||
|
||||
Interface that represents processing of chat data.
|
||||
Called from LiveChat object.
|
||||
|
||||
Parameter
|
||||
----------
|
||||
chat_components: [LIST:component]
|
||||
chat_components: List[component]
|
||||
component : dict {
|
||||
"video_id" : str
|
||||
動画ID
|
||||
"timeout" : int
|
||||
次のチャットの再読み込みまでの時間(秒)
|
||||
"chatdata" : list<object>
|
||||
チャットデータ(actions)のリスト
|
||||
Time to fetch next chat (seconds)
|
||||
"chatdata" : List[dict]
|
||||
List of chat data.
|
||||
}
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def finalize(self, *args, **kwargs):
|
||||
'''
|
||||
Interface for finalizing the process.
|
||||
Called when chat fetching finished.
|
||||
'''
|
||||
pass
|
||||
|
||||
42
pytchat/processors/combinator.py
Normal file
42
pytchat/processors/combinator.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class Combinator(ChatProcessor):
|
||||
'''
|
||||
Combinator combines multiple chat processors.
|
||||
Specify processors as tuple at `processor` params of LiveChat object.
|
||||
|
||||
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 order of return depends on parameter order.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
processors : Tuple[ChatProcessor]
|
||||
multiple processors for processing chat data
|
||||
'''
|
||||
|
||||
def __init__(self, processors: tuple):
|
||||
self.processors = processors
|
||||
|
||||
def process(self, chat_components: list):
|
||||
'''
|
||||
Called from LiveChat.get() function by user,
|
||||
or LiveChat._listen() automatically.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple of chat data processed by each chat processor.
|
||||
'''
|
||||
return tuple(processor.process(chat_components)
|
||||
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 ... import mylogger
|
||||
from .renderer.membership import LiveChatMembershipItemRenderer
|
||||
from .. chat_processor import ChatProcessor
|
||||
from ... import config
|
||||
logger = mylogger.get_logger(__name__,mode=config.LOGGER_MODE)
|
||||
logger = config.logger(__name__)
|
||||
|
||||
class CompatibleProcessor:
|
||||
|
||||
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:
|
||||
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:
|
||||
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,83 +1,82 @@
|
||||
import datetime, pytz
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
TZ_UTC = timezone(timedelta(0), 'UTC')
|
||||
|
||||
|
||||
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"]
|
||||
if author_type == '確認済み':
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
||||
if author_type == 'VERIFIED' or author_type == '確認済み':
|
||||
isVerified = True
|
||||
if author_type == '所有者':
|
||||
if author_type == 'OWNER' or author_type == '所有者':
|
||||
isChatOwner = True
|
||||
if 'メンバー' in author_type:
|
||||
if 'メンバー' in author_type or 'MEMBER' in author_type:
|
||||
isChatSponsor = True
|
||||
if author_type == 'モデレーター':
|
||||
if author_type == 'MODERATOR' or author_type == 'モデレーター':
|
||||
isChatModerator = True
|
||||
return isVerified, isChatOwner, isChatSponsor, isChatModerator
|
||||
|
||||
|
||||
def get_id(self):
|
||||
return self.renderer.get('id')
|
||||
|
||||
def get_publishedat(self,timestamp):
|
||||
dt = datetime.datetime.fromtimestamp(int(timestamp)/1000000)
|
||||
return dt.astimezone(pytz.utc).isoformat(
|
||||
timespec='milliseconds').replace('+00:00','Z')
|
||||
|
||||
|
||||
def get_publishedat(self, timestamp):
|
||||
dt = datetime.fromtimestamp(int(timestamp) / 1000000)
|
||||
return dt.astimezone(TZ_UTC).isoformat(
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'''
|
||||
YouTubeスーパーチャットで使用される通貨の記号とレート検索用の略号の
|
||||
対応表
|
||||
Table of symbols for the currencies used in YouTube Superchat.
|
||||
|
||||
Key:
|
||||
YouTubeスーパーチャットで使用される通貨の記号
|
||||
(アルファベットで終わる場合、0xA0(&npsp)が付く)
|
||||
Currency symbols used in YouTube Super Chat
|
||||
If it ends with an alphabet, it will be followed by 0xA0(&npsp).
|
||||
Value:
|
||||
fxtext: 3文字の通貨略称
|
||||
jptest: 日本語テキスト
|
||||
fxtext: ISO 4217 currency code
|
||||
jptest: japanese text
|
||||
'''
|
||||
symbols = {
|
||||
"$": {"fxtext": "USD", "jptext": "米・ドル"},
|
||||
@@ -33,5 +33,6 @@ symbols = {
|
||||
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
||||
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"}
|
||||
}
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
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,29 +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()
|
||||
}
|
||||
|
||||
class DefaultProcessor:
|
||||
def process(self, chat_components: list):
|
||||
|
||||
chatlist = []
|
||||
@@ -31,49 +134,46 @@ class DefaultProcessor:
|
||||
|
||||
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,AttributeError) as e:
|
||||
print(f"------{str(type(e))}-{str(e)}----------")
|
||||
print(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,85 +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.timestampText = tst.get("simpleText")
|
||||
self.chat.elapsedTime = tst.get("simpleText")
|
||||
else:
|
||||
self.timestampText = ""
|
||||
self.datetime = self.get_datetime(timestampUsec)
|
||||
self.message = self.get_message(self.renderer)
|
||||
self.id = self.renderer.get('id')
|
||||
self.amountValue= 0.0
|
||||
self.amountString = ""
|
||||
self.currency= ""
|
||||
self.bgColor = 0
|
||||
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 = ''
|
||||
if renderer.get("message"):
|
||||
runs=renderer["message"].get("runs")
|
||||
if runs:
|
||||
for r in runs:
|
||||
if r:
|
||||
if r.get('emoji'):
|
||||
message += r['emoji'].get('shortcuts',[''])[0]
|
||||
else:
|
||||
message += r.get('text','')
|
||||
return message
|
||||
message_ex = []
|
||||
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):
|
||||
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:
|
||||
author_type = badge["liveChatAuthorBadgeRenderer"]["accessibility"]["accessibilityData"]["label"]
|
||||
if author_type == '確認済み':
|
||||
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 == '所有者':
|
||||
if author_type == 'OWNER':
|
||||
isChatOwner = True
|
||||
if 'メンバー' in author_type:
|
||||
isChatSponsor = True
|
||||
self.get_badgeurl(badge)
|
||||
if author_type == 'モデレーター':
|
||||
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
|
||||
|
||||
@@ -33,5 +33,6 @@ symbols = {
|
||||
"ARS\xa0": {"fxtext": "ARS", "jptext": "アルゼンチン・ペソ"},
|
||||
"CLP\xa0": {"fxtext": "CLP", "jptext": "チリ・ペソ"},
|
||||
"NOK\xa0": {"fxtext": "NOK", "jptext": "ノルウェー・クローネ"},
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"}
|
||||
}
|
||||
"BAM\xa0": {"fxtext": "BAM", "jptext": "ボスニア・兌換マルカ"},
|
||||
"SGD\xa0": {"fxtext": "SGD", "jptext": "シンガポール・ドル"}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
from .base import BaseRenderer
|
||||
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,34 +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()
|
||||
|
||||
self.author.name = self.renderer["authorName"]["simpleText"]
|
||||
|
||||
amountDisplayString, symbol, amount =(
|
||||
self.get_amountdata(self.renderer)
|
||||
amountDisplayString, symbol, amount = (
|
||||
self.get_amountdata(self.item)
|
||||
)
|
||||
self.message = self.get_message(self.renderer)
|
||||
self.amountValue= amount
|
||||
self.amountString = amountDisplayString
|
||||
self.currency= currency.symbols[symbol]["fxtext"] if currency.symbols.get(symbol) else symbol
|
||||
self.bgColor= self.renderer.get("bodyBackgroundColor", 0)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import re
|
||||
from . import currency
|
||||
from .paidmessage import LiveChatPaidMessageRenderer
|
||||
|
||||
class LiveChatPaidStickerRenderer(LiveChatPaidMessageRenderer):
|
||||
def __init__(self, item):
|
||||
super().__init__(item, "superSticker")
|
||||
from .base import BaseRenderer
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
|
||||
|
||||
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.item)
|
||||
)
|
||||
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, item):
|
||||
amountDisplayString = item["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(amountDisplayString)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amount = float(m.group(2).replace(',', ''))
|
||||
else:
|
||||
symbol = ""
|
||||
amount = 0.0
|
||||
return amountDisplayString, symbol, amount
|
||||
|
||||
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"
|
||||
|
||||
10
pytchat/processors/dummy_processor.py
Normal file
10
pytchat/processors/dummy_processor.py
Normal file
@@ -0,0 +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
|
||||
170
pytchat/processors/html_archiver.py
Normal file
170
pytchat/processors/html_archiver.py
Normal file
@@ -0,0 +1,170 @@
|
||||
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']
|
||||
|
||||
HEADER_HTML = '''
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
|
||||
'''
|
||||
|
||||
TABLE_CSS = '''
|
||||
table.css {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.css thead{
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #000
|
||||
}
|
||||
|
||||
table.css tr td{
|
||||
padding: 0.3em;
|
||||
border: 1px solid #000
|
||||
}
|
||||
|
||||
table.css th{
|
||||
padding: 0.3em;
|
||||
border: 1px solid #000
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class HTMLArchiver(ChatProcessor):
|
||||
'''
|
||||
HTMLArchiver saves chat data as HTML table format.
|
||||
'''
|
||||
def __init__(self, save_path, callback=None):
|
||||
super().__init__()
|
||||
self.client = httpx.Client(http2=True)
|
||||
self.save_path = self._checkpath(save_path)
|
||||
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 = 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
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
save_path : str :
|
||||
Actual save path of file.
|
||||
total_lines : int :
|
||||
Count of total lines written to the file.
|
||||
"""
|
||||
if chat_components is None or len(chat_components) == 0:
|
||||
return self.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)
|
||||
)
|
||||
)
|
||||
if self.callback:
|
||||
self.callback(None, 1)
|
||||
self.counter += 1
|
||||
return self.save_path, self.counter
|
||||
|
||||
def _parse_html_line(self, raw_line):
|
||||
return ''.join(('<tr>',
|
||||
''.join(''.join(('<td>', cell, '</td>')) for cell in raw_line),
|
||||
'</tr>\n'))
|
||||
|
||||
def _parse_table_header(self, raw_line):
|
||||
return ''.join(('<thead><tr>',
|
||||
''.join(''.join(('<th>', cell, '</th>')) for cell in raw_line),
|
||||
'</tr></thead>\n'))
|
||||
|
||||
def _parse_message(self, message_items: list) -> str:
|
||||
return ''.join(''.join(('<span class="', self._set_emoji_table(item), '" title="', item['txt'], '"></span>'))
|
||||
if type(item) is dict else item
|
||||
for item in message_items)
|
||||
|
||||
def _encode_img(self, url):
|
||||
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 _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,13 +0,0 @@
|
||||
import json
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
class JsonDisplayProcessor(ChatProcessor):
|
||||
|
||||
def process(self,chat_components: list):
|
||||
if chat_components:
|
||||
for component in chat_components:
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata:
|
||||
for chat in chatdata:
|
||||
print(json.dumps(chat,ensure_ascii=False)[:200])
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import datetime
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
class JsonfileArchiveProcessor(ChatProcessor):
|
||||
def __init__(self,filepath):
|
||||
super().__init__()
|
||||
if os.path.exists(filepath):
|
||||
print('filepath is already exists!: ')
|
||||
print(' '+filepath)
|
||||
newpath=os.path.dirname(filepath) + \
|
||||
'/'+datetime.datetime.now() \
|
||||
.strftime('%Y-%m-%d %H-%M-%S')+'.data'
|
||||
|
||||
print('created alternate filename:')
|
||||
print(' '+newpath)
|
||||
self.filepath = newpath
|
||||
else:
|
||||
print('filepath: '+filepath)
|
||||
self.filepath = filepath
|
||||
|
||||
def process(self,chat_components: list):
|
||||
if chat_components:
|
||||
with open(self.filepath, mode='a', encoding = 'utf-8') as f:
|
||||
for component in chat_components:
|
||||
if component:
|
||||
chatdata = component.get('chatdata')
|
||||
for action in chatdata:
|
||||
if action:
|
||||
if action.get("addChatItemAction"):
|
||||
if action["addChatItemAction"]["item"].get(
|
||||
"liveChatViewerEngagementMessageRenderer"):
|
||||
continue
|
||||
s = json.dumps(action,ensure_ascii = False)
|
||||
#print(s[:200])
|
||||
f.writelines(s+'\n')
|
||||
|
||||
def _parsedir(self,_dir):
|
||||
if _dir[-1]=='\\' or _dir[-1]=='/':
|
||||
separator =''
|
||||
else:
|
||||
separator ='/'
|
||||
os.makedirs(_dir + separator, exist_ok=True)
|
||||
return _dir + separator
|
||||
|
||||
69
pytchat/processors/jsonfile_archiver.py
Normal file
69
pytchat/processors/jsonfile_archiver.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from .chat_processor import ChatProcessor
|
||||
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
|
||||
|
||||
class JsonfileArchiver(ChatProcessor):
|
||||
"""
|
||||
JsonfileArchiver saves chat data as text of JSON lines.
|
||||
|
||||
Parameter:
|
||||
----------
|
||||
save_path : str :
|
||||
save path of file.If a file with the same name exists,
|
||||
it is automatically saved under a different name
|
||||
with suffix '(number)'
|
||||
"""
|
||||
|
||||
def __init__(self, save_path):
|
||||
super().__init__()
|
||||
self.save_path = self._checkpath(save_path)
|
||||
self.line_counter = 0
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
save_path : str :
|
||||
Actual save path of file.
|
||||
total_lines : int :
|
||||
count of total lines written to the file.
|
||||
"""
|
||||
if chat_components is None:
|
||||
return
|
||||
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||
for component in chat_components:
|
||||
if component is None:
|
||||
continue
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata is None:
|
||||
continue
|
||||
for action in chatdata:
|
||||
if action is None:
|
||||
continue
|
||||
json_line = json.dumps(action, ensure_ascii=False)
|
||||
f.writelines(json_line + '\n')
|
||||
self.line_counter += 1
|
||||
return {"save_path": self.save_path,
|
||||
"total_lines": self.line_counter}
|
||||
|
||||
def _checkpath(self, filepath):
|
||||
splitter = os.path.splitext(os.path.basename(filepath))
|
||||
body = splitter[0]
|
||||
extention = splitter[1]
|
||||
newpath = filepath
|
||||
counter = 0
|
||||
while os.path.exists(newpath):
|
||||
match = re.search(PATTERN, body)
|
||||
if match:
|
||||
counter = int(match[2]) + 1
|
||||
num_with_bracket = f'({str(counter)})'
|
||||
body = f'{match[1]}{num_with_bracket}'
|
||||
else:
|
||||
body = f'{body}({str(counter)})'
|
||||
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||
return newpath
|
||||
@@ -1,47 +1,49 @@
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
import datetime
|
||||
import time
|
||||
from .chat_processor import ChatProcessor
|
||||
##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 ''
|
||||
|
||||
|
||||
|
||||
0
pytchat/processors/speed/__init__.py
Normal file
0
pytchat/processors/speed/__init__.py
Normal file
203
pytchat/processors/speed/calculator.py
Normal file
203
pytchat/processors/speed/calculator.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
speed_calculator.py
|
||||
チャットの勢いを算出するChatProcessor
|
||||
Calculate speed of chat.
|
||||
"""
|
||||
import time
|
||||
from .. chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class RingQueue:
|
||||
"""
|
||||
リング型キュー
|
||||
|
||||
Attributes
|
||||
----------
|
||||
items : list
|
||||
格納されているアイテムのリスト。
|
||||
first_pos : int
|
||||
キュー内の一番古いアイテムを示すリストのインデックス。
|
||||
last_pos : int
|
||||
キュー内の一番新しいアイテムを示すリストのインデックス。
|
||||
mergin : boolean
|
||||
キュー内に余裕があるか。キュー内のアイテム個数が、キューの最大個数未満であればTrue。
|
||||
"""
|
||||
|
||||
def __init__(self, capacity):
|
||||
"""
|
||||
コンストラクタ
|
||||
|
||||
Parameter
|
||||
----------
|
||||
capacity:このキューに格納するアイテムの最大個数。
|
||||
格納時に最大個数を超える場合は一番古いアイテムから
|
||||
上書きする。
|
||||
"""
|
||||
if capacity <= 0:
|
||||
raise ValueError
|
||||
self.items = list()
|
||||
self.capacity = capacity
|
||||
self.first_pos = 0
|
||||
self.last_pos = 0
|
||||
self.mergin = True
|
||||
|
||||
def put(self, item):
|
||||
"""
|
||||
引数itemに指定されたアイテムをこのキューに格納する。
|
||||
キューの最大個数を超える場合は、一番古いアイテムの位置に上書きする。
|
||||
|
||||
Parameter
|
||||
----------
|
||||
item:格納するアイテム
|
||||
"""
|
||||
if self.mergin:
|
||||
self.items.append(item)
|
||||
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:
|
||||
self.last_pos = 0
|
||||
self.items[self.last_pos] = item
|
||||
|
||||
self.first_pos += 1
|
||||
if self.first_pos > self.capacity - 1:
|
||||
self.first_pos = 0
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
キュー内の一番古いアイテムへの参照を返す
|
||||
(アイテムは削除しない)
|
||||
|
||||
Return
|
||||
----------
|
||||
キュー内の一番古いアイテムへの参照
|
||||
"""
|
||||
return self.items[self.first_pos]
|
||||
|
||||
def item_count(self):
|
||||
return len(self.items)
|
||||
|
||||
|
||||
class SpeedCalculator(ChatProcessor, RingQueue):
|
||||
"""
|
||||
チャットの勢いを計算する。
|
||||
|
||||
一定期間のチャットデータのうち、最初のチャットの投稿時刻と
|
||||
最後のチャットの投稿時刻の差を、チャット数で割り返し
|
||||
1分あたりの速度に換算する。
|
||||
|
||||
Parameter
|
||||
----------
|
||||
capacity : int
|
||||
RingQueueに格納するチャット勢い算出用データの最大数
|
||||
"""
|
||||
|
||||
def __init__(self, capacity=10):
|
||||
super().__init__(capacity)
|
||||
self.speed = 0
|
||||
|
||||
def process(self, chat_components: list):
|
||||
chatdata = []
|
||||
if chat_components:
|
||||
for component in chat_components:
|
||||
if component.get("chatdata"):
|
||||
chatdata.extend(component.get("chatdata"))
|
||||
|
||||
self._put_chatdata(chatdata)
|
||||
self.speed = self._calc_speed()
|
||||
return self.speed
|
||||
|
||||
def _calc_speed(self):
|
||||
"""
|
||||
RingQueue内のチャット勢い算出用データリストを元に、
|
||||
チャット速度を計算して返す
|
||||
|
||||
Return
|
||||
---------------------------
|
||||
チャット速度(1分間で換算したチャット数)
|
||||
"""
|
||||
try:
|
||||
# キュー内の総チャット数
|
||||
total = sum(item['chat_count'] for item in self.items)
|
||||
# キュー内の最初と最後のチャットの時間差
|
||||
duration = (self.items[self.last_pos]['endtime'] - self.items[self.first_pos]['starttime'])
|
||||
if duration != 0:
|
||||
return int(total * 60 / duration)
|
||||
return 0
|
||||
except IndexError:
|
||||
return 0
|
||||
|
||||
def _put_chatdata(self, actions):
|
||||
"""
|
||||
チャットデータからタイムスタンプを読み取り、勢い測定用のデータを組み立て、
|
||||
RingQueueに投入する。
|
||||
200円以上のスパチャはtickerとmessageの2つのデータが生成されるが、
|
||||
tickerの方は時刻データの場所が異なることを利用し、勢いの集計から除外している。
|
||||
Parameter
|
||||
---------
|
||||
actions : List[dict]
|
||||
チャットデータ(addChatItemAction) のリスト
|
||||
"""
|
||||
def _put_emptydata():
|
||||
'''
|
||||
チャットデータがない場合に空のデータをキューに投入する。
|
||||
'''
|
||||
timestamp_now = int(time.time())
|
||||
self.put({
|
||||
'chat_count': 0,
|
||||
'starttime': int(timestamp_now),
|
||||
'endtime': int(timestamp_now)
|
||||
})
|
||||
|
||||
def _get_timestamp(action: dict):
|
||||
"""
|
||||
チャットデータから時刻データを取り出す。
|
||||
"""
|
||||
try:
|
||||
item = action['addChatItemAction']['item']
|
||||
timestamp = int(item[list(item.keys())[0]]['timestampUsec'])
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
return timestamp
|
||||
|
||||
if actions is None or len(actions) == 0:
|
||||
_put_emptydata()
|
||||
return
|
||||
|
||||
# actions内の時刻データを持つチャットデータの数
|
||||
counter = 0
|
||||
# actions内の最初のチャットデータの時刻
|
||||
starttime = None
|
||||
# actions内の最後のチャットデータの時刻
|
||||
endtime = None
|
||||
|
||||
for action in actions:
|
||||
# チャットデータからtimestampUsecを読み取る
|
||||
gettime = _get_timestamp(action)
|
||||
|
||||
# 時刻のないデータだった場合は次の行のデータで読み取り試行
|
||||
if gettime is None:
|
||||
continue
|
||||
|
||||
# 最初に有効な時刻を持つデータのtimestampをstarttimeに設定
|
||||
if starttime is None:
|
||||
starttime = gettime
|
||||
|
||||
# 最後のtimestampを設定(途中で時刻のないデータの場合もあるので上書きしていく)
|
||||
endtime = gettime
|
||||
|
||||
# チャットの数をインクリメント
|
||||
counter += 1
|
||||
|
||||
# チャット速度用のデータを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)
|
||||
})
|
||||
0
pytchat/processors/superchat/__init__.py
Normal file
0
pytchat/processors/superchat/__init__.py
Normal file
75
pytchat/processors/superchat/calculator.py
Normal file
75
pytchat/processors/superchat/calculator.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import re
|
||||
from pytchat.processors.chat_processor import ChatProcessor
|
||||
|
||||
superchat_regex = re.compile(r"^(\D*)(\d{1,3}(,\d{3})*(\.\d*)*\b)$")
|
||||
|
||||
items_paid = [
|
||||
'addChatItemAction',
|
||||
'item',
|
||||
'liveChatPaidMessageRenderer'
|
||||
]
|
||||
|
||||
items_sticker = [
|
||||
'addChatItemAction',
|
||||
'item',
|
||||
'liveChatPaidStickerRenderer'
|
||||
]
|
||||
|
||||
|
||||
class SuperchatCalculator(ChatProcessor):
|
||||
"""
|
||||
Calculate the amount of SuperChat by currency.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = {}
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Return
|
||||
------------
|
||||
results : dict :
|
||||
List of amount by currency.
|
||||
key: currency symbol, value: total amount.
|
||||
"""
|
||||
if chat_components is None:
|
||||
return self.results
|
||||
for component in chat_components:
|
||||
chatdata = component.get('chatdata')
|
||||
if chatdata is None:
|
||||
continue
|
||||
for action in chatdata:
|
||||
renderer = self._get_item(action, items_paid) or \
|
||||
self._get_item(action, items_sticker)
|
||||
if renderer is None:
|
||||
continue
|
||||
symbol, amount = self._parse(renderer)
|
||||
self.results.setdefault(symbol, 0)
|
||||
self.results[symbol] += amount
|
||||
return self.results
|
||||
|
||||
def _parse(self, renderer):
|
||||
purchase_amount_text = renderer["purchaseAmountText"]["simpleText"]
|
||||
m = superchat_regex.search(purchase_amount_text)
|
||||
if m:
|
||||
symbol = m.group(1)
|
||||
amount = float(m.group(2).replace(',', ''))
|
||||
else:
|
||||
symbol = ""
|
||||
amount = 0.0
|
||||
return symbol, amount
|
||||
|
||||
def _get_item(self, dict_body, items: list):
|
||||
for item in items:
|
||||
if dict_body is None:
|
||||
break
|
||||
if isinstance(dict_body, dict):
|
||||
dict_body = dict_body.get(item)
|
||||
continue
|
||||
if isinstance(item, int) and \
|
||||
isinstance(dict_body, list) and \
|
||||
len(dict_body) > item:
|
||||
dict_body = dict_body[item]
|
||||
continue
|
||||
return None
|
||||
return dict_body
|
||||
67
pytchat/processors/tsv_archiver.py
Normal file
67
pytchat/processors/tsv_archiver.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
from .chat_processor import ChatProcessor
|
||||
from .default.processor import DefaultProcessor
|
||||
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
fmt_headers = ['datetime', 'elapsed', 'authorName', 'message',
|
||||
'superchatAmount', 'authorType', 'authorChannel']
|
||||
|
||||
|
||||
class TSVArchiver(ChatProcessor):
|
||||
'''
|
||||
TsvArchiver saves chat data as Tab Separated Values format text.
|
||||
'''
|
||||
|
||||
def __init__(self, save_path):
|
||||
super().__init__()
|
||||
self.save_path = self._checkpath(save_path)
|
||||
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||
writer = csv.writer(f, delimiter='\t')
|
||||
writer.writerow(fmt_headers)
|
||||
self.processor = DefaultProcessor()
|
||||
|
||||
def _checkpath(self, filepath):
|
||||
splitter = os.path.splitext(os.path.basename(filepath))
|
||||
body = splitter[0]
|
||||
extention = splitter[1]
|
||||
newpath = filepath
|
||||
counter = 0
|
||||
while os.path.exists(newpath):
|
||||
match = re.search(PATTERN, body)
|
||||
if match:
|
||||
counter = int(match[2]) + 1
|
||||
num_with_bracket = f'({str(counter)})'
|
||||
body = f'{match[1]}{num_with_bracket}'
|
||||
else:
|
||||
body = f'{body}({str(counter)})'
|
||||
newpath = os.path.join(os.path.dirname(filepath), body + extention)
|
||||
return newpath
|
||||
|
||||
def process(self, chat_components: list):
|
||||
"""
|
||||
Returns
|
||||
----------
|
||||
dict :
|
||||
save_path : str :
|
||||
Actual save path of file.
|
||||
total_lines : int :
|
||||
count of total lines written to the file.
|
||||
"""
|
||||
if chat_components is None or len(chat_components) == 0:
|
||||
return
|
||||
|
||||
with open(self.save_path, mode='a', encoding='utf-8') as f:
|
||||
writer = csv.writer(f, delimiter='\t')
|
||||
chats = self.processor.process(chat_components).items
|
||||
for c in chats:
|
||||
writer.writerow([
|
||||
c.datetime,
|
||||
c.elapsedTime,
|
||||
c.author.name,
|
||||
c.message,
|
||||
c.amountString,
|
||||
c.author.type,
|
||||
c.author.channelId
|
||||
])
|
||||
@@ -1,15 +1,120 @@
|
||||
import requests,json,datetime
|
||||
import datetime
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from .. import config
|
||||
from .. exceptions import InvalidVideoIdException
|
||||
|
||||
def download(url):
|
||||
_session = requests.Session()
|
||||
PATTERN = re.compile(r"(.*)\(([0-9]+)\)$")
|
||||
|
||||
PATTERN_YTURL = re.compile(r"((?<=(v|V)/)|(?<=be/)|(?<=(\?|\&)v=)|(?<=embed/))([\w-]+)")
|
||||
|
||||
PATTERN_CHANNEL = re.compile(r"\\\"channelId\\\":\\\"(.{24})\\\"")
|
||||
|
||||
YT_VIDEO_ID_LENGTH = 11
|
||||
|
||||
CLIENT_VERSION = ''.join(("2.", (datetime.datetime.today() - datetime.timedelta(days=1)).strftime("%Y%m%d"), ".01.00"))
|
||||
|
||||
UA = config.headers["user-agent"]
|
||||
|
||||
|
||||
def extract(url):
|
||||
_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):
|
||||
with open(str(datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S')
|
||||
)+filename,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
|
||||
|
||||
|
||||
def get_param(continuation, replay=False, offsetms: int = 0, dat=''):
|
||||
if offsetms < 0:
|
||||
offsetms = 0
|
||||
ret = {
|
||||
"context": {
|
||||
"client": {
|
||||
"visitorData": dat,
|
||||
"userAgent": UA,
|
||||
"clientName": "WEB",
|
||||
"clientVersion": CLIENT_VERSION,
|
||||
},
|
||||
|
||||
},
|
||||
"continuation": continuation,
|
||||
}
|
||||
if replay:
|
||||
ret.setdefault("currentPlayerState", {
|
||||
"playerOffsetMs": str(int(offsetms))})
|
||||
return ret
|
||||
|
||||
|
||||
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_YTURL, 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
|
||||
|
||||
|
||||
def get_channelid(client, video_id):
|
||||
resp = client.get("https://www.youtube.com/embed/{}".format(quote(video_id)), headers=config.headers)
|
||||
match = re.search(PATTERN_CHANNEL, resp.text)
|
||||
if match is None:
|
||||
raise InvalidVideoIdException(f"Cannot find channel id for video id:{video_id}. This video id seems to be invalid.")
|
||||
try:
|
||||
ret = match.group(1)
|
||||
except IndexError:
|
||||
raise InvalidVideoIdException(f"Invalid video id: {video_id}")
|
||||
return ret
|
||||
|
||||
async def get_channelid_async(client, video_id):
|
||||
resp = await client.get("https://www.youtube.com/embed/{}".format(quote(video_id)), headers=config.headers)
|
||||
match = re.search(PATTERN_CHANNEL, resp.text)
|
||||
if match is None:
|
||||
raise InvalidVideoIdException(f"Cannot find channel id for video id:{video_id}. This video id seems to be invalid.")
|
||||
try:
|
||||
ret = match.group(1)
|
||||
except IndexError:
|
||||
raise InvalidVideoIdException(f"Invalid video id: {video_id}")
|
||||
return ret
|
||||
@@ -1,4 +1 @@
|
||||
aiohttp
|
||||
pytz
|
||||
requests
|
||||
urllib3
|
||||
httpx[http2]==0.16.1
|
||||
@@ -1,5 +1,2 @@
|
||||
aioresponses
|
||||
mock
|
||||
mocker
|
||||
pytest
|
||||
pytest-mock
|
||||
pytest-mock
|
||||
pytest-httpx
|
||||
|
||||
55
setup.py
55
setup.py
@@ -1,6 +1,5 @@
|
||||
from setuptools import setup, find_packages, Command
|
||||
from codecs import open
|
||||
from os import path, system
|
||||
from os import path, system, remove, rename, removedirs
|
||||
import re
|
||||
|
||||
package_name = "pytchat"
|
||||
@@ -8,19 +7,27 @@ package_name = "pytchat"
|
||||
root_dir = path.abspath(path.dirname(__file__))
|
||||
|
||||
def _requirements():
|
||||
return [name.rstrip() for name in open(path.join(root_dir, 'requirements.txt')).readlines()]
|
||||
return [name.rstrip()
|
||||
for name in open(path.join(
|
||||
root_dir, 'requirements.txt')).readlines()]
|
||||
|
||||
def _test_requirements():
|
||||
return [name.rstrip() for name in open(path.join(root_dir, 'requirements_test.txt')).readlines()]
|
||||
|
||||
return [name.rstrip()
|
||||
for name in open(path.join(
|
||||
root_dir, 'requirements_test.txt')).readlines()]
|
||||
|
||||
with open(path.join(root_dir, package_name, '__init__.py')) as f:
|
||||
init_text = f.read()
|
||||
version = re.search(r'__version__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
license = re.search(r'__license__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
author = re.search(r'__author__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
author_email = re.search(r'__author_email__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
url = re.search(r'__url__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
version = re.search(
|
||||
r'__version__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
license = re.search(
|
||||
r'__license__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
author = re.search(
|
||||
r'__author__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
author_email = re.search(
|
||||
r'__author_email__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
url = re.search(
|
||||
r'__url__\s*=\s*[\'\"](.+?)[\'\"]', init_text).group(1)
|
||||
|
||||
assert version
|
||||
assert license
|
||||
@@ -28,35 +35,33 @@ assert author
|
||||
assert author_email
|
||||
assert url
|
||||
|
||||
|
||||
|
||||
|
||||
with open('README.md', encoding='utf-8') as f:
|
||||
long_description = f.read()
|
||||
|
||||
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
packages=find_packages(),
|
||||
version=version,
|
||||
url=url,
|
||||
author=author,
|
||||
author_email=author_email,
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/markdown',
|
||||
license=license,
|
||||
install_requires=_requirements(),
|
||||
tests_require=_test_requirements(),
|
||||
description="a python library for fetching youtube live chat.",
|
||||
classifiers=[
|
||||
'Natural Language :: Japanese',
|
||||
'Development Status :: 4 - Beta',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
],
|
||||
description="a python library for fetching youtube live chat.",
|
||||
install_requires=_requirements(),
|
||||
keywords='youtube livechat asyncio',
|
||||
|
||||
license=license,
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/markdown',
|
||||
name=package_name,
|
||||
packages=find_packages(exclude=['*log.txt','*tests','*testrun']),
|
||||
url=url,
|
||||
version=version,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
import pytest
|
||||
from pytchat.parser.replay import Parser
|
||||
import pytchat.config as config
|
||||
import requests, json
|
||||
from pytchat.paramgen import arcparam
|
||||
|
||||
def test_arcparam_0(mocker):
|
||||
param = arcparam.get("01234567890")
|
||||
assert "op2w0wRyGjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QoATAAOABAAEgEUhwIABAAGAAgACoOc3RhdGljY2hlY2tzdW1AAFgDYAFoAXIECAEQAXgA" == param
|
||||
|
||||
|
||||
def test_arcparam_1(mocker):
|
||||
param = arcparam.get("01234567890", seektime = 100000)
|
||||
assert "op2w0wR3GjxDZzhhRFFvTE1ERXlNelExTmpjNE9UQWFFLXFvM2JrQkRRb0xNREV5TXpRMU5qYzRPVEFnQVElM0QlM0QogNDbw_QCMAA4AEAASANSHAgAEAAYACAAKg5zdGF0aWNjaGVja3N1bUAAWANgAWgBcgQIARABeAA%3D" == param
|
||||
|
||||
def test_arcparam_2(mocker):
|
||||
param = arcparam.get("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)
|
||||
jsn = json.loads(resp.text)
|
||||
parser = Parser()
|
||||
_ , chatdata = parser.parse(jsn)
|
||||
test_id = chatdata[0]["addChatItemAction"]["item"]["liveChatTextMessageRenderer"]["id"]
|
||||
print(test_id)
|
||||
assert "CjoKGkNMYXBzZTdudHVVQ0Zjc0IxZ0FkTnFnQjVREhxDSnlBNHV2bnR1VUNGV0dnd2dvZDd3NE5aZy0w" == test_id
|
||||
|
||||
140
tests/test_calculator_get_item.py
Normal file
140
tests/test_calculator_get_item.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from pytchat.processors.superchat.calculator import SuperchatCalculator
|
||||
|
||||
get_item = SuperchatCalculator()._get_item
|
||||
|
||||
dict_test = {
|
||||
'root':{
|
||||
'node0' : 'value0',
|
||||
'node1' : 'value1',
|
||||
'node2' : {
|
||||
'node2-0' : 'value2-0'
|
||||
},
|
||||
|
||||
'node3' : [
|
||||
{'node3-0' : 'value3-0'},
|
||||
{'node3-1' :
|
||||
{'node3-1-0' : 'value3-1-0'}
|
||||
}
|
||||
],
|
||||
'node4' : [],
|
||||
'node5' : [
|
||||
[
|
||||
{'node5-1-0' : 'value5-1-0'},
|
||||
{'node5-1-1' : 'value5-1-1'},
|
||||
],
|
||||
{'node5-0' : 'value5-0'},
|
||||
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
items_test0 = [
|
||||
'root',
|
||||
'node1'
|
||||
]
|
||||
|
||||
|
||||
items_test_not_found0 = [
|
||||
'root',
|
||||
'other_data'
|
||||
]
|
||||
|
||||
|
||||
items_test_nest = [
|
||||
'root',
|
||||
'node2',
|
||||
'node2-0'
|
||||
]
|
||||
|
||||
items_test_list0 = [
|
||||
'root',
|
||||
'node3',
|
||||
1,
|
||||
'node3-1'
|
||||
]
|
||||
|
||||
items_test_list1 = [
|
||||
'root',
|
||||
'node3',
|
||||
1,
|
||||
'node3-1',
|
||||
'node3-1-0'
|
||||
]
|
||||
|
||||
items_test_list2 = [
|
||||
'root',
|
||||
'node4',
|
||||
None
|
||||
]
|
||||
|
||||
items_test_list3 = [
|
||||
'root',
|
||||
'node4'
|
||||
]
|
||||
|
||||
items_test_list_nest = [
|
||||
'root',
|
||||
'node5',
|
||||
0,
|
||||
1,
|
||||
'node5-1-1'
|
||||
]
|
||||
|
||||
items_test_list_nest_not_found1 = [
|
||||
'root',
|
||||
'node5',
|
||||
0,
|
||||
1,
|
||||
'node5-1-1',
|
||||
'nodez'
|
||||
]
|
||||
|
||||
items_test_not_found1 = [
|
||||
'root',
|
||||
'node3',
|
||||
2,
|
||||
'node3-1',
|
||||
'node3-1-0'
|
||||
]
|
||||
|
||||
items_test_not_found2 = [
|
||||
'root',
|
||||
'node3',
|
||||
2,
|
||||
'node3-1',
|
||||
'node3-1-0',
|
||||
'nodex'
|
||||
]
|
||||
def test_get_items_0():
|
||||
assert get_item(dict_test, items_test0) == 'value1'
|
||||
|
||||
def test_get_items_1():
|
||||
assert get_item(dict_test, items_test_not_found0) is None
|
||||
|
||||
def test_get_items_2():
|
||||
assert get_item(dict_test, items_test_nest) == 'value2-0'
|
||||
|
||||
def test_get_items_3():
|
||||
assert get_item(
|
||||
dict_test, items_test_list0) == {'node3-1-0' : 'value3-1-0'}
|
||||
|
||||
def test_get_items_4():
|
||||
assert get_item(dict_test, items_test_list1) == 'value3-1-0'
|
||||
|
||||
def test_get_items_5():
|
||||
assert get_item(dict_test, items_test_not_found1) == None
|
||||
|
||||
def test_get_items_6():
|
||||
assert get_item(dict_test, items_test_not_found2) == None
|
||||
|
||||
def test_get_items_7():
|
||||
assert get_item(dict_test, items_test_list2) == None
|
||||
|
||||
def test_get_items_8():
|
||||
assert get_item(dict_test, items_test_list_nest) == 'value5-1-1'
|
||||
|
||||
def test_get_items_9():
|
||||
assert get_item(dict_test, items_test_list_nest_not_found1) == None
|
||||
|
||||
def test_get_items_10():
|
||||
assert get_item(dict_test, items_test_list3) == []
|
||||
72
tests/test_calculator_parse.py
Normal file
72
tests/test_calculator_parse.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.superchat.calculator import SuperchatCalculator
|
||||
from pytchat.exceptions import ChatParseException
|
||||
parse = SuperchatCalculator()._parse
|
||||
|
||||
|
||||
def _open_file(path):
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def load_chatdata(filepath):
|
||||
parser = Parser(is_replay=True)
|
||||
# print(json.loads(_open_file(filepath)))
|
||||
contents = parser.get_contents(json.loads(_open_file(filepath)))[0]
|
||||
return parser.parse(contents)[1]
|
||||
|
||||
|
||||
def test_parse_1():
|
||||
renderer = {"purchaseAmountText": {"simpleText": "¥2,000"}}
|
||||
symbol, amount = parse(renderer)
|
||||
assert symbol == '¥'
|
||||
assert amount == 2000.0
|
||||
|
||||
|
||||
def test_parse_2():
|
||||
renderer = {"purchaseAmountText": {"simpleText": "ABC\x0a200"}}
|
||||
symbol, amount = parse(renderer)
|
||||
assert symbol == 'ABC\x0a'
|
||||
assert amount == 200.0
|
||||
|
||||
|
||||
def test_process_0():
|
||||
"""
|
||||
parse superchat data
|
||||
"""
|
||||
chat_component = {
|
||||
'video_id': '',
|
||||
'timeout': 10,
|
||||
'chatdata': load_chatdata(r"tests/testdata/calculator/superchat_0.json")
|
||||
}
|
||||
assert SuperchatCalculator().process([chat_component]) == {
|
||||
'¥': 6800.0, '€': 2.0}
|
||||
|
||||
|
||||
def test_process_1():
|
||||
"""
|
||||
parse no superchat data
|
||||
"""
|
||||
chat_component = {
|
||||
'video_id': '',
|
||||
'timeout': 10,
|
||||
'chatdata': load_chatdata(r"tests/testdata/calculator/text_only.json")
|
||||
}
|
||||
assert SuperchatCalculator().process([chat_component]) == {}
|
||||
|
||||
|
||||
def test_process_2():
|
||||
"""
|
||||
try to parse after replay end
|
||||
"""
|
||||
try:
|
||||
chat_component = {
|
||||
'video_id': '',
|
||||
'timeout': 10,
|
||||
'chatdata': load_chatdata(r"tests/testdata/calculator/replay_end.json")
|
||||
}
|
||||
assert False
|
||||
SuperchatCalculator().process([chat_component])
|
||||
except ChatParseException:
|
||||
assert True
|
||||
@@ -1,18 +1,9 @@
|
||||
import json
|
||||
import 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)
|
||||
|
||||
parser = Parser()
|
||||
|
||||
def test_textmessage(mocker):
|
||||
'''api互換processorのテスト:通常テキストメッセージ'''
|
||||
@@ -20,36 +11,39 @@ def test_textmessage(mocker):
|
||||
|
||||
_json = _open_file("tests/testdata/compatible/textmessage.json")
|
||||
|
||||
_, chatdata = parser.parse(json.loads(_json))
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
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のテスト:メンバ新規登録'''
|
||||
@@ -57,34 +51,72 @@ def test_newsponcer(mocker):
|
||||
|
||||
_json = _open_file("tests/testdata/compatible/newSponsor.json")
|
||||
|
||||
_, chatdata = parser.parse(json.loads(_json))
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
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))[0])
|
||||
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):
|
||||
@@ -93,38 +125,56 @@ def test_superchat(mocker):
|
||||
|
||||
_json = _open_file("tests/testdata/compatible/superchat.json")
|
||||
|
||||
_, chatdata = parser.parse(json.loads(_json))
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
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()
|
||||
|
||||
_json = _open_file("tests/testdata/unregistered_currency.json")
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
|
||||
data = {
|
||||
"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))[0])
|
||||
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))[0])
|
||||
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))[0])
|
||||
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))[0])
|
||||
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))[0])
|
||||
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))[0])
|
||||
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()
|
||||
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 import extract_video_id
|
||||
from pytchat.exceptions import InvalidVideoIdException
|
||||
|
||||
VALID_TEST_PATTERNS = (
|
||||
("ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||
("vid_test_be", "vid_test_be"),
|
||||
("https://www.youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||
("https://www.youtube.com/watch?v=123_456_789&t=123s", "123_456_789"),
|
||||
("www.youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||
("watch?v=123_456_789", "123_456_789"),
|
||||
("youtube.com/watch?v=123_456_789", "123_456_789"),
|
||||
("http://youtu.be/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||
("youtu.be/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||
("https://www.youtube.com/watch?v=ABC_EFG_IJK&list=XYZ_ABC_12345&start_radio=1&t=1", "ABC_EFG_IJK"),
|
||||
("https://www.youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||
("www.youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK"),
|
||||
("youtube.com/embed/ABC_EFG_IJK", "ABC_EFG_IJK")
|
||||
)
|
||||
|
||||
INVALID_TEST_PATTERNS = (
|
||||
("", ""),
|
||||
("0123456789", "0123456789"), # less than 11 letters id
|
||||
("more_than_11_letter_string", "more_than_11_letter_string"),
|
||||
("https://www.youtube.com/watch?v=more_than_11_letter_string", "more_than_11_letter_string"),
|
||||
("https://www.youtube.com/channel/123_456_789", "123_456_789"),
|
||||
)
|
||||
|
||||
TYPEERROR_TEST_PATTERNS = (
|
||||
(100, 100), # not string
|
||||
(["123_456_789"], "123_456_789"), # not string
|
||||
)
|
||||
|
||||
|
||||
def test_extract_valid_pattern():
|
||||
for pattern in VALID_TEST_PATTERNS:
|
||||
ret = extract_video_id(pattern[0])
|
||||
assert ret == pattern[1]
|
||||
|
||||
|
||||
def test_extract_invalid_pattern():
|
||||
for pattern in INVALID_TEST_PATTERNS:
|
||||
try:
|
||||
extract_video_id(pattern[0])
|
||||
assert False
|
||||
except InvalidVideoIdException:
|
||||
assert True
|
||||
|
||||
|
||||
def test_extract_typeerror_pattern():
|
||||
for pattern in TYPEERROR_TEST_PATTERNS:
|
||||
try:
|
||||
extract_video_id(pattern[0])
|
||||
assert False
|
||||
except TypeError:
|
||||
assert True
|
||||
48
tests/test_jsonfile_archiver.py
Normal file
48
tests/test_jsonfile_archiver.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
from pytchat.processors.jsonfile_archiver import JsonfileArchiver
|
||||
from unittest.mock import patch, mock_open
|
||||
from tests.testdata.jsonfile_archiver.chat_component import chat_component
|
||||
|
||||
def _open_file(path):
|
||||
with open(path,mode ='r',encoding = 'utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
def test_checkpath(mocker):
|
||||
processor = JsonfileArchiver("path")
|
||||
mocker.patch('os.path.exists').side_effect = exists_file
|
||||
'''Test no duplicate file.'''
|
||||
assert processor._checkpath("z:/other.txt") == "z:/other.txt"
|
||||
|
||||
'''Test duplicate filename.
|
||||
The case the name first renamed ('test.txt -> test(0).txt')
|
||||
is also duplicated.
|
||||
'''
|
||||
assert processor._checkpath("z:/test.txt") == "z:/test(1).txt"
|
||||
|
||||
'''Test no extention file (duplicate).'''
|
||||
assert processor._checkpath("z:/test") == "z:/test(0)"
|
||||
|
||||
|
||||
def test_read_write():
|
||||
'''Test read and write chatdata'''
|
||||
mock = mock_open(read_data = "")
|
||||
with patch('builtins.open',mock):
|
||||
processor = JsonfileArchiver("path")
|
||||
save_path = processor.process([chat_component])
|
||||
fh = mock()
|
||||
actuals = [args[0] for (args, kwargs) in fh.writelines.call_args_list]
|
||||
'''write format is json dump string with 0x0A'''
|
||||
to_be_written = [json.dumps(action, ensure_ascii=False)+'\n'
|
||||
for action in chat_component["chatdata"]]
|
||||
for i in range(len(actuals)):
|
||||
assert actuals[i] == to_be_written[i]
|
||||
assert save_path == {'save_path': 'path', 'total_lines': 7}
|
||||
|
||||
|
||||
def exists_file(path):
|
||||
if path == "z:/test.txt":
|
||||
return True
|
||||
if path == "z:/test(0).txt":
|
||||
return True
|
||||
if path == "z:/test":
|
||||
return True
|
||||
@@ -1,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
|
||||
|
||||
71
tests/test_livechat_2.py
Normal file
71
tests/test_livechat_2.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pytest_httpx import HTTPXMock
|
||||
from concurrent.futures import CancelledError
|
||||
from pytchat.core_multithread.livechat import LiveChat
|
||||
from pytchat.core_async.livechat import LiveChatAsync
|
||||
from pytchat.processors.dummy_processor import DummyProcessor
|
||||
|
||||
|
||||
def _open_file(path):
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
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 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())
|
||||
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"
|
||||
chat.terminate()
|
||||
@@ -4,6 +4,6 @@ from pytchat.paramgen import liveparam
|
||||
def test_liveparam_0(mocker):
|
||||
_ts1= 1546268400
|
||||
param = liveparam._build("01234567890",
|
||||
*([_ts1*1000000 for i in range(5)]))
|
||||
test_param="0ofMyAPiARp8Q2c4S0RRb0xNREV5TXpRMU5qYzRPVEFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5TURFeU16UTFOamM0T1RBbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiAuNbVqsrfAjAAOABAAkorCAEQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgLjW1arK3wJYA1CAuNbVqsrfAliAuNbVqsrfAmgBggEECAEQAIgBAKABgLjW1arK3wI%3D"
|
||||
*([_ts1*1000000 for i in range(5)]), topchat_only=False)
|
||||
test_param="0ofMyAN1GhxDZzhLRFFvTE1ERXlNelExTmpjNE9UQWdBUT09KIC41tWqyt8CMAA4AEABShsIABAAGAAgADoAQABKAFCAuNbVqsrfAlgDeABQgLjW1arK3wJYgLjW1arK3wJoAYIBAggBiAEAmgECCACgAYC41tWqyt8C"
|
||||
assert test_param == param
|
||||
@@ -1,44 +1,34 @@
|
||||
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()
|
||||
|
||||
@aioresponses()
|
||||
|
||||
def test_finishedlive(*mock):
|
||||
'''配信が終了した動画を正しく処理できるか'''
|
||||
|
||||
_text = _open_file('tests/testdata/finished_live.json')
|
||||
_text = json.loads(_text)
|
||||
|
||||
try:
|
||||
parser.parse(_text)
|
||||
try:
|
||||
parser.parse(parser.get_contents(_text)[0])
|
||||
assert False
|
||||
except NoContentsException:
|
||||
except NoContents:
|
||||
assert True
|
||||
|
||||
@aioresponses()
|
||||
def test_parsejson(*mock):
|
||||
'''jsonを正常にパースできるか'''
|
||||
|
||||
def test_parsejson(*mock):
|
||||
_text = _open_file('tests/testdata/paramgen_firstread.json')
|
||||
_text = json.loads(_text)
|
||||
|
||||
try:
|
||||
parser.parse(_text)
|
||||
jsn = _text
|
||||
timeout = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["timeoutMs"]
|
||||
continuation = jsn["response"]["continuationContents"]["liveChatContinuation"]["continuations"][0]["timedContinuationData"]["continuation"]
|
||||
assert 5035 == timeout
|
||||
assert "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D" == continuation
|
||||
except:
|
||||
assert False
|
||||
try:
|
||||
s, _ = parser.parse(parser.get_contents(_text)[0])
|
||||
assert s['timeoutMs'] == 5035
|
||||
assert s['continuation'] == "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%3D"
|
||||
except Exception:
|
||||
assert False
|
||||
|
||||
64
tests/test_speed_calculator.py
Normal file
64
tests/test_speed_calculator.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from pytchat.parser.live import Parser
|
||||
from pytchat.processors.speed.calculator import SpeedCalculator
|
||||
|
||||
parser = Parser(is_replay=False)
|
||||
|
||||
|
||||
def test_speed_1(mocker):
|
||||
'''test speed calculation with normal json.
|
||||
test json has 15 chatdata, duration is 30 seconds,
|
||||
so the speed of chatdata is 30 chats/minute.
|
||||
'''
|
||||
|
||||
processor = SpeedCalculator(capacity=30)
|
||||
|
||||
_json = _open_file("tests/testdata/speed/speedtest_normal.json")
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
data = {
|
||||
"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.
|
||||
'''
|
||||
processor = SpeedCalculator(capacity=30)
|
||||
|
||||
_json = _open_file("tests/testdata/speed/speedtest_undefined.json")
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
data = {
|
||||
"video_id": "",
|
||||
"timeout": 10,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert ret == 0
|
||||
|
||||
|
||||
def test_speed_3(mocker):
|
||||
'''test speed calculation with empty data.
|
||||
'''
|
||||
processor = SpeedCalculator(capacity=30)
|
||||
|
||||
_json = _open_file("tests/testdata/speed/speedtest_empty.json")
|
||||
|
||||
_, chatdata = parser.parse(parser.get_contents(json.loads(_json))[0])
|
||||
data = {
|
||||
"video_id": "",
|
||||
"timeout": 10,
|
||||
"chatdata": chatdata
|
||||
}
|
||||
ret = processor.process([data])
|
||||
assert ret == 0
|
||||
|
||||
|
||||
def _open_file(path):
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
18
tests/testdata/calculator/replay_end.json
vendored
Normal file
18
tests/testdata/calculator/replay_end.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"webResponseContextExtensionData": ""
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"playerSeekContinuationData": {
|
||||
"continuation": "___reload_continuation___"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3322
tests/testdata/calculator/superchat_0.json
vendored
Normal file
3322
tests/testdata/calculator/superchat_0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
87
tests/testdata/calculator/text_only.json
vendored
Normal file
87
tests/testdata/calculator/text_only.json
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"responseContext": {
|
||||
"webResponseContextExtensionData": ""
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"invalidationContinuationData": {
|
||||
"invalidationId": {
|
||||
"objectSource": 1000,
|
||||
"objectId": "___objectId___",
|
||||
"topic": "chat~00000000000~0000000",
|
||||
"subscribeToGcmTopics": true,
|
||||
"protoCreationTimestampMs": "1577804400000"
|
||||
},
|
||||
"timeoutMs": 10000,
|
||||
"continuation": "___continuation___"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"replayChatItemAction": {
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatTextMessageRenderer": {
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "dummy_message"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "author_name"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/------------/AAAAAAAAAAA/AAAAAAAAAAA/xxxxxxxxxxxx/s32-x-x-xx-xx-xx-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "___params___"
|
||||
}
|
||||
},
|
||||
"id": "dummy_id",
|
||||
"timestampUsec": 0,
|
||||
"authorExternalChannelId": "http://www.youtube.com/channel/author_channel_url",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
},
|
||||
"timestampText": {
|
||||
"simpleText": "0:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "dummy_client_id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"videoOffsetTimeMsec": "10000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
164
tests/testdata/chat.json
vendored
Normal file
164
tests/testdata/chat.json
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 164
|
||||
}
|
||||
},
|
||||
"csn": "",
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [{
|
||||
"service": "CSI",
|
||||
"params": [{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": ""
|
||||
}, {
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
}, {
|
||||
"key": "cver",
|
||||
"value": "2.20191219.03.01"
|
||||
}, {
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}]
|
||||
}, {
|
||||
"service": "GFEEDBACK",
|
||||
"params": [{
|
||||
"key": "e",
|
||||
"value": ""
|
||||
}, {
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}]
|
||||
}, {
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}]
|
||||
}, {
|
||||
"service": "ECATCHER",
|
||||
"params": [{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
}, {
|
||||
"key": "client.version",
|
||||
"value": "2.2"
|
||||
}, {
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "228"
|
||||
}, {
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "2858"
|
||||
}, {
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_"
|
||||
}, {
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "154"
|
||||
}, {
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "e"
|
||||
}, {
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}]
|
||||
}],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "ADw",
|
||||
"visitorData": "%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 10000,
|
||||
"continuation": "continuation"
|
||||
}
|
||||
}],
|
||||
"actions": [{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatTextMessageRenderer": {
|
||||
"message": {
|
||||
"runs": [{
|
||||
"text": "message"
|
||||
}]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "authorName"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [{
|
||||
"url": "https://yt3.ggpht.com/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
}, {
|
||||
"url": "https://yt3.ggpht.com/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "params"
|
||||
}
|
||||
},
|
||||
"id": "id",
|
||||
"timestampUsec": "1576851922945411",
|
||||
"authorBadges": [{
|
||||
"liveChatAuthorBadgeRenderer": {
|
||||
"customThumbnail": {
|
||||
"thumbnails": [{
|
||||
"url": "https://yt3.ggpht.com/photo.jpg"
|
||||
}, {
|
||||
"url": "https://yt3.ggpht.com/photo.jpg"
|
||||
}]
|
||||
},
|
||||
"tooltip": "メンバー(6 か月)",
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "メンバー(6 か月)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"authorExternalChannelId": "UC",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "00000000000000000000"
|
||||
}
|
||||
}
|
||||
]}
|
||||
},
|
||||
|
||||
"xsrf_token": "xsrf_token",
|
||||
"url": "/live_chat/get_live_chat?continuation=0",
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0",
|
||||
"rootVe": 0
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6172
tests/testdata/chatreplay.json
vendored
6172
tests/testdata/chatreplay.json
vendored
File diff suppressed because it is too large
Load Diff
3679
tests/testdata/compatible/newSponsor.json
vendored
3679
tests/testdata/compatible/newSponsor.json
vendored
File diff suppressed because it is too large
Load Diff
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
541
tests/testdata/compatible/superchat.json
vendored
541
tests/testdata/compatible/superchat.json
vendored
@@ -1,282 +1,281 @@
|
||||
{
|
||||
"xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 148
|
||||
}
|
||||
"xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 148
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D",
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x9290108c05344647"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20191001.04.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20191001"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "272006966"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "272166268"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190930_5_RC0"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1569863426"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "1a800c1a2396906f1cbb7f670d43b6f5"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 8860,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D",
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x9290108c05344647"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20191001.04.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"timestampUsec": "1569940638420061",
|
||||
"authorName": {
|
||||
"simpleText": "九十九 万"
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20191001"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "272006966"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "272166268"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190930_5_RC0"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1569863426"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "1a800c1a2396906f1cbb7f670d43b6f5"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 8860,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"timestampUsec": "1569940638420061",
|
||||
"authorName": {
|
||||
"simpleText": "九十九 万"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "ボルガ博士お許しください代"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"addLiveChatTickerItemAction": {
|
||||
"item": {
|
||||
"liveChatTickerPaidMessageItemRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"amount": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"amountTextColor": 4278190080,
|
||||
"startBackgroundColor": 4280150454,
|
||||
"endBackgroundColor": 4278239141,
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"durationSec": 120,
|
||||
"showItemEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"showLiveChatItemEndpoint": {
|
||||
"renderer": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"timestampUsec": "1569940638420061",
|
||||
"authorName": {
|
||||
"simpleText": "九十九 万"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "ボルガ博士お許しください代"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"fullDurationSec": 120
|
||||
}
|
||||
},
|
||||
"durationSec": "120"
|
||||
}
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "ボルガ博士お許しください代"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"addLiveChatTickerItemAction": {
|
||||
"item": {
|
||||
"liveChatTickerPaidMessageItemRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"amount": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"amountTextColor": 4278190080,
|
||||
"startBackgroundColor": 4280150454,
|
||||
"endBackgroundColor": 4278239141,
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"durationSec": 120,
|
||||
"showItemEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"showLiveChatItemEndpoint": {
|
||||
"renderer": {
|
||||
"liveChatPaidMessageRenderer": {
|
||||
"id": "ChwKGkNOblpoTXFrLS1RQ0ZRSU9ZQW9kclhrRXNn",
|
||||
"timestampUsec": "1569940638420061",
|
||||
"authorName": {
|
||||
"simpleText": "九十九 万"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-FoBvzoKhbZs/AAAAAAAAAAI/AAAAAAAAAAA/oyb-UCYFbfA/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥846"
|
||||
},
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "ボルガ博士お許しください代"
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerBackgroundColor": 4278239141,
|
||||
"headerTextColor": 4278190080,
|
||||
"bodyBackgroundColor": 4280150454,
|
||||
"bodyTextColor": 4278190080,
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"authorNameTextColor": 2315255808,
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMDV1V21oTmNXc3RMVkZEUmxGSlQxbEJiMlJ5V0d0RmMyY1FBQm80Q2cwS0N6Z3dUM3AwU21oMU56YzRLaWNLR0ZWRFNYbDBUbU52ZWpSd1YzcFlaa3hrWVRCRWIxVk1VUklMT0RCUGVuUkthSFUzTnpnZ0FpZ0JNaG9LR0ZWRGIxWjBRMDE2WnpKMmJHVkJWbk5oTjJ0M01EVkJRUSUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"timestampColor": 2147483648,
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"authorExternalChannelId": "UCoVtCMzg2vleAVsa7kw05AA",
|
||||
"fullDurationSec": 120
|
||||
}
|
||||
},
|
||||
"durationSec": "120"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
388
tests/testdata/compatible/supersticker.json
vendored
388
tests/testdata/compatible/supersticker.json
vendored
@@ -1,197 +1,195 @@
|
||||
{
|
||||
"xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 148
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D",
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x9290108c05344647"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20191001.04.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20191001"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "272006966"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "272166268"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190930_5_RC0"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1569863426"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "1a800c1a2396906f1cbb7f670d43b6f5"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 8860,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidStickerRenderer": {
|
||||
"id": "ChwKGkNQX2Qzb2pUcU9VQ0ZRdnVXQW9kaTNJS3NB",
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMUJmWkROdmFsUnhUMVZEUmxGMmRWZEJiMlJwTTBsTGMwRVFBQm80Q2cwS0N6VlVUSE42U0hNd2QxYzBLaWNLR0ZWRFdGSnNTVXN6UTNkZlZFcEpVVU0xYTFOS1NsRk5aeElMTlZSTWMzcEljekIzVnpRZ0FpZ0JNaG9LR0ZWRFRHOXJPVWQ0WVRGYU5rWTVWV3d5WVV0MlRFWkdadyUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
},
|
||||
"timestampUsec": "1571499325098699",
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https: //yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "りお"
|
||||
},
|
||||
"authorExternalChannelId": "UCLok9Gxa1Z6F9Ul2aKvLFFg",
|
||||
"sticker": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s72-rwa",
|
||||
"width": 72,
|
||||
"height": 72
|
||||
},
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s144-rwa",
|
||||
"width": 144,
|
||||
"height": 144
|
||||
}
|
||||
],
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "気付いてもらえるように人差し指を上げたり下げたりしている柴犬"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moneyChipBackgroundColor": 4278248959,
|
||||
"moneyChipTextColor": 4278190080,
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥200"
|
||||
},
|
||||
"stickerDisplayWidth": 72,
|
||||
"stickerDisplayHeight": 72,
|
||||
"backgroundColor": 4278237396,
|
||||
"authorNameTextColor": 3003121664
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"xsrf_token": "QUFFLUhqbWVqWVRTUjhBcGRkNWR5Q2F3VWhxcTd0RkF1UXxBQ3Jtc0trMThFVGVVNTFnWmZucnkwNlJJMEZ2bndUS1I3b2dpLUtTNE92dEgwX3Y0MkNZU2NXdGY1QTMtX09BUGphYUpDQlc1dFhiTm9jLS1sQXVCNHpsdllqcm1id0t1RFBCM3A1b2o3OGt0Yjd6TE5wcmxBbjNyQktjc1lWZ3hjM1RuYk83YkQ0VVN3MGUybjAwSE90SS1YNkxvMUV5YVE=",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 148
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D"
|
||||
}
|
||||
},
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCja2bHMpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DXxuTMpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABxM3xzKT75AI%253D",
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x9290108c05344647"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20191001.04.00"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23788875,23793834,23794620,23804281,23806159,23816483,23819244,23820768,23826780,23827354,23830392,23832125,23835020,23836965,23837741,23837772,23837993,23838235,23839362,23840155,23840217,23841118,23841454,23842630,23842662,23842883,23842986,23843289,23843534,23843767,23845644,9449243,9471239,9474360"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20191001"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "272006966"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "272166268"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190930_5_RC0"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1569863426"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "1a800c1a2396906f1cbb7f670d43b6f5"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n2STXd2iKZr2gAOt9qvgCg",
|
||||
"visitorData": "CgtPQm1xTmtvNm1Tcyifyc3sBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 8860,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xPREJQZW5SS2FIVTNOemdhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5T0RCUGVuUkthSFUzTnpnbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCid_ejQpPvkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQwJKPoqT75AJYA1DF-ovRpPvkAliL_fvZoPvkAmgBggEECAEQAIgBAKABtNic0aT75AI%3D"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatPaidStickerRenderer": {
|
||||
"id": "ChwKGkNQX2Qzb2pUcU9VQ0ZRdnVXQW9kaTNJS3NB",
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2g0S0hBb2FRMUJmWkROdmFsUnhUMVZEUmxGMmRWZEJiMlJwTTBsTGMwRVFBQm80Q2cwS0N6VlVUSE42U0hNd2QxYzBLaWNLR0ZWRFdGSnNTVXN6UTNkZlZFcEpVVU0xYTFOS1NsRk5aeElMTlZSTWMzcEljekIzVnpRZ0FpZ0JNaG9LR0ZWRFRHOXJPVWQ0WVRGYU5rWTVWV3d5WVV0MlRFWkdadyUzRCUzRA=="
|
||||
}
|
||||
},
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
},
|
||||
"timestampUsec": "1571499325098699",
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https: //yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-xRQVNtDSO3w/AAAAAAAAAAI/AAAAAAAAAAA/Is9D9D7wwAE/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "りお"
|
||||
},
|
||||
"authorExternalChannelId": "UCLok9Gxa1Z6F9Ul2aKvLFFg",
|
||||
"sticker": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s72-rwa",
|
||||
"width": 72,
|
||||
"height": 72
|
||||
},
|
||||
{
|
||||
"url": "//lh3.googleusercontent.com/1aIk6vlk4gZ2ytc42j3WcIHYtWFWo2uVWVqbHFuxiGHO4XwyAS0u8vuu6VkiX5eR6uy9mfAupyP786_TbP0=s144-rwa",
|
||||
"width": 144,
|
||||
"height": 144
|
||||
}
|
||||
],
|
||||
"accessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "気付いてもらえるように人差し指を上げたり下げたりしている柴犬"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moneyChipBackgroundColor": 4278248959,
|
||||
"moneyChipTextColor": 4278190080,
|
||||
"purchaseAmountText": {
|
||||
"simpleText": "¥200"
|
||||
},
|
||||
"stickerDisplayWidth": 72,
|
||||
"stickerDisplayHeight": 72,
|
||||
"backgroundColor": 4278237396,
|
||||
"authorNameTextColor": 3003121664
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
348
tests/testdata/compatible/textmessage.json
vendored
348
tests/testdata/compatible/textmessage.json
vendored
@@ -1,177 +1,175 @@
|
||||
{
|
||||
"response": {
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x3eff0db28fc39bbe"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20190920.05.01"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23748146,23788851,23788875,23793834,23804281,23807353,23808952,23828082,23828243,23829333,23832544,23834418,23834656,23835020,23836434,23836965,23837742,23837772,23837993,23838301,23838576,23838576,23838742,23839360,23840216,23841655,23842986,23843288,23843533,23843743,23844780,24630231,9425362,9449243,9466592,9469037,9471235,9474358"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20190920"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "270169303"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "270377311"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190919_5_RC1"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1568942548"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "392d499f55b5e2c240adde58886a8143"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n96GXabRGouFlQTigY2YDg",
|
||||
"visitorData": "CgtKUldQeGJJRXhkcyifvZvsBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 5041,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCj7hLmSs-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1DsuuGSs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKAB7KDVk7Pj5AI%3D"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatTextMessageRenderer": {
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "name"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2pzS09Rb2FRMHRQTkhRMVEzbzBMVkZEUmxweGRrUlJiMlJZV0d0QlVHY1NHME5PYm5OdlVESm1OQzFSUTBaUmRsTlhRVzlrVVZGclJUTlJNeEFBR2pnS0RRb0xhelJMZDBsWFQwdERkalFxSndvWVZVTnZTWEZ1TVZvMWFYaERXbmRqTUVSWFNqZHlTME5uRWd0ck5FdDNTVmRQUzBOMk5DQUNLQUV5R2dvWVZVTnlOVGxXVlY5amRtWnlkVEF0YW1GeWNtUk1NMDEz"
|
||||
}
|
||||
},
|
||||
"id": "CjkKGkNLTzR0NUN6NC1RQ0ZacXZEUW9kWFhrQVBnEhtDTm5zb1AyZjQtUUNGUXZTV0FvZFFRa0UzUTM%3D",
|
||||
"timestampUsec": "1569119896722467",
|
||||
"authorExternalChannelId": "UCr59VU_cvfru0-jarrdL3Mw",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "CNnsoP2f4-QCFQvSWAodQQkE3Q3"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D"
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D"
|
||||
}
|
||||
},
|
||||
"csn": "n96GXabRGouFlQTigY2YDg",
|
||||
"xsrf_token": "QUFFLUhqbHNNWTF3NFJqc2h3cGE1NE9FWGdaWk5mRlVhUXxBQ3Jtc0tuTWhZNFcyTW1iZnA3ZnFTYUFudVFEUVE0cnFEOVBGcEU1MEh0Zlh4bll1amVmRl9OMkxZV3pKV1ZSbExBeDctTl95NGtBVnJZdlNxeS1KdWVNempEN2N6MHhaU1laV3hnVkZPeHp1OHVDTGVFSGUyOGduT0szbDV5N05LYUZTdzdoTDRwV1VJWndaVjdQVGRjNWVpR0YwUXgtZXc=",
|
||||
"url": "\/live_chat\/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 81
|
||||
}
|
||||
"responseContext": {
|
||||
"serviceTrackingParams": [
|
||||
{
|
||||
"service": "CSI",
|
||||
"params": [
|
||||
{
|
||||
"key": "GetLiveChat_rid",
|
||||
"value": "0x3eff0db28fc39bbe"
|
||||
},
|
||||
{
|
||||
"key": "c",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "cver",
|
||||
"value": "2.20190920.05.01"
|
||||
},
|
||||
{
|
||||
"key": "yt_li",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GFEEDBACK",
|
||||
"params": [
|
||||
{
|
||||
"key": "e",
|
||||
"value": "23744176,23748146,23788851,23788875,23793834,23804281,23807353,23808952,23828082,23828243,23829333,23832544,23834418,23834656,23835020,23836434,23836965,23837742,23837772,23837993,23838301,23838576,23838576,23838742,23839360,23840216,23841655,23842986,23843288,23843533,23843743,23844780,24630231,9425362,9449243,9466592,9469037,9471235,9474358"
|
||||
},
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "GUIDED_HELP",
|
||||
"params": [
|
||||
{
|
||||
"key": "logged_in",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "ECATCHER",
|
||||
"params": [
|
||||
{
|
||||
"key": "client.name",
|
||||
"value": "WEB"
|
||||
},
|
||||
{
|
||||
"key": "client.version",
|
||||
"value": "2.20190920"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.changelist",
|
||||
"value": "270169303"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.experiments.source_version",
|
||||
"value": "270377311"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.label",
|
||||
"value": "youtube.ytfe.innertube_20190919_5_RC1"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.timestamp",
|
||||
"value": "1568942548"
|
||||
},
|
||||
{
|
||||
"key": "innertube.build.variants.checksum",
|
||||
"value": "392d499f55b5e2c240adde58886a8143"
|
||||
},
|
||||
{
|
||||
"key": "innertube.run.job",
|
||||
"value": "ytfe-innertube-replica-only.ytfe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"webResponseContextExtensionData": {
|
||||
"ytConfigData": {
|
||||
"csn": "n96GXabRGouFlQTigY2YDg",
|
||||
"visitorData": "CgtKUldQeGJJRXhkcyifvZvsBQ%3D%3D"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuationContents": {
|
||||
"liveChatContinuation": {
|
||||
"continuations": [
|
||||
{
|
||||
"timedContinuationData": {
|
||||
"timeoutMs": 5041,
|
||||
"continuation": "0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCj7hLmSs-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1DsuuGSs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKAB7KDVk7Pj5AI%3D"
|
||||
}
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"addChatItemAction": {
|
||||
"item": {
|
||||
"liveChatTextMessageRenderer": {
|
||||
"message": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorName": {
|
||||
"simpleText": "name"
|
||||
},
|
||||
"authorPhoto": {
|
||||
"thumbnails": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s32-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 32,
|
||||
"height": 32
|
||||
},
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/-8sLtPu1Hyw0/AAAAAAAAAAI/AAAAAAAAAAA/a_52bWnC0-s/s64-c-k-no-mo-rj-c0xffffff/photo.jpg",
|
||||
"width": 64,
|
||||
"height": 64
|
||||
}
|
||||
]
|
||||
},
|
||||
"contextMenuEndpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"ignoreNavigation": true
|
||||
}
|
||||
},
|
||||
"liveChatItemContextMenuEndpoint": {
|
||||
"params": "Q2pzS09Rb2FRMHRQTkhRMVEzbzBMVkZEUmxweGRrUlJiMlJZV0d0QlVHY1NHME5PYm5OdlVESm1OQzFSUTBaUmRsTlhRVzlrVVZGclJUTlJNeEFBR2pnS0RRb0xhelJMZDBsWFQwdERkalFxSndvWVZVTnZTWEZ1TVZvMWFYaERXbmRqTUVSWFNqZHlTME5uRWd0ck5FdDNTVmRQUzBOMk5DQUNLQUV5R2dvWVZVTnlOVGxXVlY5amRtWnlkVEF0YW1GeWNtUk1NMDEz"
|
||||
}
|
||||
},
|
||||
"id": "CjkKGkNLTzR0NUN6NC1RQ0ZacXZEUW9kWFhrQVBnEhtDTm5zb1AyZjQtUUNGUXZTV0FvZFFRa0UzUTM%3D",
|
||||
"timestampUsec": "1569119896722467",
|
||||
"authorExternalChannelId": "UCr59VU_cvfru0-jarrdL3Mw",
|
||||
"contextMenuAccessibility": {
|
||||
"accessibilityData": {
|
||||
"label": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"clientId": "CNnsoP2f4-QCFQvSWAodQQkE3Q3"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"commandMetadata": {
|
||||
"webCommandMetadata": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D"
|
||||
}
|
||||
},
|
||||
"urlEndpoint": {
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D"
|
||||
}
|
||||
},
|
||||
"csn": "n96GXabRGouFlQTigY2YDg",
|
||||
"xsrf_token": "QUFFLUhqbHNNWTF3NFJqc2h3cGE1NE9FWGdaWk5mRlVhUXxBQ3Jtc0tuTWhZNFcyTW1iZnA3ZnFTYUFudVFEUVE0cnFEOVBGcEU1MEh0Zlh4bll1amVmRl9OMkxZV3pKV1ZSbExBeDctTl95NGtBVnJZdlNxeS1KdWVNempEN2N6MHhaU1laV3hnVkZPeHp1OHVDTGVFSGUyOGduT0szbDV5N05LYUZTdzdoTDRwV1VJWndaVjdQVGRjNWVpR0YwUXgtZXc=",
|
||||
"url": "/live_chat/get_live_chat?continuation=0ofMyAPiARp8Q2c4S0RRb0xhelJMZDBsWFQwdERkalFhUTZxNXdiMEJQUW83YUhSMGNITTZMeTkzZDNjdWVXOTFkSFZpWlM1amIyMHZiR2wyWlY5amFHRjBQM1k5YXpSTGQwbFhUMHREZGpRbWFYTmZjRzl3YjNWMFBURWdBZyUzRCUzRCiPz5-Os-PkAjAAOABAAUorCAAQABgAIAAqDnN0YXRpY2NoZWNrc3VtOgBAAEoCCAFQgJqXjrPj5AJYA1CRwciOs-PkAli3pNq1k-PkAmgBggEECAEQAIgBAKABjbfnjrPj5AI%253D",
|
||||
"timing": {
|
||||
"info": {
|
||||
"st": 81
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
tests/testdata/default/newSponsor_current.json
vendored
Normal file
98
tests/testdata/default/newSponsor_current.json
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"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": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
80
tests/testdata/default/newSponsor_lagacy.json
vendored
Normal file
80
tests/testdata/default/newSponsor_lagacy.json
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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": "コメントの操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
110
tests/testdata/default/replay_member_text.json
vendored
Normal file
110
tests/testdata/default/replay_member_text.json
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
182
tests/testdata/default/superchat.json
vendored
Normal file
182
tests/testdata/default/superchat.json
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
97
tests/testdata/default/supersticker.json
vendored
Normal file
97
tests/testdata/default/supersticker.json
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
77
tests/testdata/default/textmessage.json
vendored
Normal file
77
tests/testdata/default/textmessage.json
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
6126
tests/testdata/extract_duplcheck/head/dp0-0.json
vendored
Normal file
6126
tests/testdata/extract_duplcheck/head/dp0-0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/head/dp0-1.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/head/dp0-1.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/head/dp0-2.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/head/dp0-2.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/head/dp0-3.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/head/dp0-3.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2527
tests/testdata/extract_duplcheck/head/dp0-4.json
vendored
Normal file
2527
tests/testdata/extract_duplcheck/head/dp0-4.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1429
tests/testdata/extract_duplcheck/head/dp0-5.json
vendored
Normal file
1429
tests/testdata/extract_duplcheck/head/dp0-5.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6126
tests/testdata/extract_duplcheck/overlap/dp0-0.json
vendored
Normal file
6126
tests/testdata/extract_duplcheck/overlap/dp0-0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/overlap/dp0-1.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/overlap/dp0-1.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/overlap/dp0-2.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/overlap/dp0-2.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/extract_duplcheck/overlap/dp0-3.json
vendored
Normal file
3076
tests/testdata/extract_duplcheck/overlap/dp0-3.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2527
tests/testdata/extract_duplcheck/overlap/dp0-4.json
vendored
Normal file
2527
tests/testdata/extract_duplcheck/overlap/dp0-4.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1429
tests/testdata/extract_duplcheck/overlap/dp0-5.json
vendored
Normal file
1429
tests/testdata/extract_duplcheck/overlap/dp0-5.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3076
tests/testdata/fetch_patch/pt0-0.json
vendored
Normal file
3076
tests/testdata/fetch_patch/pt0-0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user