webinardump 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- webinardump-0.1.1/.gitignore +1 -0
- webinardump-0.1.1/PKG-INFO +121 -0
- webinardump-0.1.1/README.md +109 -0
- webinardump-0.1.1/pyproject.toml +82 -0
- webinardump-0.1.1/src/webinardump/__init__.py +1 -0
- webinardump-0.1.1/src/webinardump/cli.py +46 -0
- webinardump-0.1.1/src/webinardump/dumpers/__init__.py +9 -0
- webinardump-0.1.1/src/webinardump/dumpers/base.py +242 -0
- webinardump-0.1.1/src/webinardump/dumpers/webinarru.py +51 -0
- webinardump-0.1.1/src/webinardump/dumpers/yadisk.py +57 -0
- webinardump-0.1.1/src/webinardump/utils.py +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dump/
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webinardump
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Make local backup copies of webinars
|
|
5
|
+
Project-URL: Homepage, https://github.com/idlesign/webinardump
|
|
6
|
+
Author-email: Igor Starikov <idlesign@yandex.ru>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
Keywords: backup,webinars
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: requests>=2.31.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# webinardump
|
|
14
|
+
|
|
15
|
+
<https://github.com/idlesign/webinardump>
|
|
16
|
+
|
|
17
|
+
[](https://pypi.python.org/pypi/webinardump)
|
|
18
|
+
[](https://pypi.python.org/pypi/webinardump)
|
|
19
|
+
[](https://coveralls.io/r/idlesign/webinardump)
|
|
20
|
+
|
|
21
|
+
## Описание
|
|
22
|
+
|
|
23
|
+
*Приложение позволяет скачать запись вебинара и сохранить в виде .mp4 файла.*
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Откуда качает
|
|
27
|
+
|
|
28
|
+
* Яндекс.Диск (записи стримов)
|
|
29
|
+
* webinar.ru
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Зависимости
|
|
33
|
+
|
|
34
|
+
Что нужно иметь для запуска приложения и работы с ним.
|
|
35
|
+
|
|
36
|
+
* Linux (Unix)
|
|
37
|
+
* Python 3.11+
|
|
38
|
+
* ffmpeg (для Ubuntu: `sudo apt install ffmpeg`)
|
|
39
|
+
* uv (для установки и обновления приложения)
|
|
40
|
+
* Базовые знания о работе в браузере с отладочной консолью.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Установка и обновление
|
|
44
|
+
|
|
45
|
+
Производится при помощи приложения [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
$ uv tool install webinardump
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
После этого запускать приложение можно командой
|
|
52
|
+
|
|
53
|
+
```shell
|
|
54
|
+
$ webinardump
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Для обновления выполните
|
|
58
|
+
|
|
59
|
+
```shell
|
|
60
|
+
$ uv tool upgrade webinardump
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Как использовать
|
|
64
|
+
|
|
65
|
+
Переместитесь в желаемый каталог и выполните следующую команду.
|
|
66
|
+
|
|
67
|
+
```shell
|
|
68
|
+
|
|
69
|
+
; Указываем путь для скачивания - my_webinar_dir/
|
|
70
|
+
; Указываем таймаут запросов - 10 секунд
|
|
71
|
+
; Указываем максимальное количество одновременных запросов - 20
|
|
72
|
+
uv run webinar.py --target my_webinar_dir/ --timeout 10 --rmax 20
|
|
73
|
+
```
|
|
74
|
+
Приложение скачает фрагменты вебинара, а потом соберёт из них единый файл.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
### disk.yandex.ru
|
|
78
|
+
|
|
79
|
+
1. Взять ссылку на вебинар (запись стрима). Вида https://disk.yandex.ru/i/xxx
|
|
80
|
+
2. Запустить скачиватель и скормить ему ссылку из предыдущего пункта.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### webinar.ru
|
|
84
|
+
|
|
85
|
+
Процесс скачивания автоматизирован не полностью, потребуется искать
|
|
86
|
+
некоторые ссылки при помощи браузера.
|
|
87
|
+
|
|
88
|
+
1. Взять ссылку на вебинар. Вида https://events.webinar.ru/event/xxx/yyy/zzz
|
|
89
|
+
2. Открыть в браузере.
|
|
90
|
+
3. Включить отладочную консоль (F12).
|
|
91
|
+
4. Запустить воспроизведение.
|
|
92
|
+
5. Отыскать ссылку с `record-new/` и запомнить её.
|
|
93
|
+
6. Отыскать ссылку, оканчивающуюся на `chunklist.m3u8` и запомнить её.
|
|
94
|
+
7. Запустить скачиватель и скормить ему ссылки и двух предыдущих пунктов.
|
|
95
|
+
|
|
96
|
+
## Для разработки
|
|
97
|
+
|
|
98
|
+
При разработке используется [makeapp](https://pypi.org/project/makeapp/). Ставим:
|
|
99
|
+
|
|
100
|
+
```shell
|
|
101
|
+
$ uv tool install makeapp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
После клонирования репозитория sponsrdump, в его директории выполняем:
|
|
105
|
+
|
|
106
|
+
```shell
|
|
107
|
+
# ставим утилиты
|
|
108
|
+
$ ma tools
|
|
109
|
+
|
|
110
|
+
# инициализируем виртуальное окружение
|
|
111
|
+
$ ma up --tool
|
|
112
|
+
|
|
113
|
+
# теперь в окружении доступны зависимости и команда sponsrdump
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Проверь стиль перед отправкой кода на обзор:
|
|
117
|
+
|
|
118
|
+
```shell
|
|
119
|
+
# проверяем стиль
|
|
120
|
+
$ ma style
|
|
121
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# webinardump
|
|
2
|
+
|
|
3
|
+
<https://github.com/idlesign/webinardump>
|
|
4
|
+
|
|
5
|
+
[](https://pypi.python.org/pypi/webinardump)
|
|
6
|
+
[](https://pypi.python.org/pypi/webinardump)
|
|
7
|
+
[](https://coveralls.io/r/idlesign/webinardump)
|
|
8
|
+
|
|
9
|
+
## Описание
|
|
10
|
+
|
|
11
|
+
*Приложение позволяет скачать запись вебинара и сохранить в виде .mp4 файла.*
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Откуда качает
|
|
15
|
+
|
|
16
|
+
* Яндекс.Диск (записи стримов)
|
|
17
|
+
* webinar.ru
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Зависимости
|
|
21
|
+
|
|
22
|
+
Что нужно иметь для запуска приложения и работы с ним.
|
|
23
|
+
|
|
24
|
+
* Linux (Unix)
|
|
25
|
+
* Python 3.11+
|
|
26
|
+
* ffmpeg (для Ubuntu: `sudo apt install ffmpeg`)
|
|
27
|
+
* uv (для установки и обновления приложения)
|
|
28
|
+
* Базовые знания о работе в браузере с отладочной консолью.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Установка и обновление
|
|
32
|
+
|
|
33
|
+
Производится при помощи приложения [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
$ uv tool install webinardump
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
После этого запускать приложение можно командой
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
$ webinardump
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Для обновления выполните
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
$ uv tool upgrade webinardump
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Как использовать
|
|
52
|
+
|
|
53
|
+
Переместитесь в желаемый каталог и выполните следующую команду.
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
|
|
57
|
+
; Указываем путь для скачивания - my_webinar_dir/
|
|
58
|
+
; Указываем таймаут запросов - 10 секунд
|
|
59
|
+
; Указываем максимальное количество одновременных запросов - 20
|
|
60
|
+
uv run webinar.py --target my_webinar_dir/ --timeout 10 --rmax 20
|
|
61
|
+
```
|
|
62
|
+
Приложение скачает фрагменты вебинара, а потом соберёт из них единый файл.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
### disk.yandex.ru
|
|
66
|
+
|
|
67
|
+
1. Взять ссылку на вебинар (запись стрима). Вида https://disk.yandex.ru/i/xxx
|
|
68
|
+
2. Запустить скачиватель и скормить ему ссылку из предыдущего пункта.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### webinar.ru
|
|
72
|
+
|
|
73
|
+
Процесс скачивания автоматизирован не полностью, потребуется искать
|
|
74
|
+
некоторые ссылки при помощи браузера.
|
|
75
|
+
|
|
76
|
+
1. Взять ссылку на вебинар. Вида https://events.webinar.ru/event/xxx/yyy/zzz
|
|
77
|
+
2. Открыть в браузере.
|
|
78
|
+
3. Включить отладочную консоль (F12).
|
|
79
|
+
4. Запустить воспроизведение.
|
|
80
|
+
5. Отыскать ссылку с `record-new/` и запомнить её.
|
|
81
|
+
6. Отыскать ссылку, оканчивающуюся на `chunklist.m3u8` и запомнить её.
|
|
82
|
+
7. Запустить скачиватель и скормить ему ссылки и двух предыдущих пунктов.
|
|
83
|
+
|
|
84
|
+
## Для разработки
|
|
85
|
+
|
|
86
|
+
При разработке используется [makeapp](https://pypi.org/project/makeapp/). Ставим:
|
|
87
|
+
|
|
88
|
+
```shell
|
|
89
|
+
$ uv tool install makeapp
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
После клонирования репозитория sponsrdump, в его директории выполняем:
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
# ставим утилиты
|
|
96
|
+
$ ma tools
|
|
97
|
+
|
|
98
|
+
# инициализируем виртуальное окружение
|
|
99
|
+
$ ma up --tool
|
|
100
|
+
|
|
101
|
+
# теперь в окружении доступны зависимости и команда sponsrdump
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Проверь стиль перед отправкой кода на обзор:
|
|
105
|
+
|
|
106
|
+
```shell
|
|
107
|
+
# проверяем стиль
|
|
108
|
+
$ ma style
|
|
109
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "webinardump"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Make local backup copies of webinars"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Igor Starikov", email = "idlesign@yandex.ru" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "BSD-3-Clause"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["backup", "webinars"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.31.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/idlesign/webinardump"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
webinardump = "webinardump.cli:main"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
{include-group = "linters"},
|
|
26
|
+
{include-group = "tests"},
|
|
27
|
+
]
|
|
28
|
+
linters = [
|
|
29
|
+
# "ruff",
|
|
30
|
+
]
|
|
31
|
+
tests = [
|
|
32
|
+
"pytest",
|
|
33
|
+
"pytest-responsemock",
|
|
34
|
+
"pytest-datafixtures",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
path = "src/webinardump/__init__.py"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/webinardump"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
packages = ["src/"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = [
|
|
52
|
+
"tests",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.coverage.run]
|
|
56
|
+
source = [
|
|
57
|
+
"src/",
|
|
58
|
+
]
|
|
59
|
+
omit = [
|
|
60
|
+
"*/cli.py",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
fail_under = 99.00
|
|
65
|
+
exclude_also = [
|
|
66
|
+
"raise NotImplementedError",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.tox]
|
|
71
|
+
skip_missing_interpreters = true
|
|
72
|
+
env_list = [
|
|
73
|
+
"py311",
|
|
74
|
+
"py312",
|
|
75
|
+
"py313",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.tox.env_run_base]
|
|
79
|
+
dependency_groups = ["tests"]
|
|
80
|
+
commands = [
|
|
81
|
+
["pytest", { replace = "posargs", default = ["tests"], extend = true }],
|
|
82
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = '0.1.1'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .dumpers import Dumper
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_user_input(param: str, hint: str, *, choices: list[str] | None = None) -> str:
|
|
9
|
+
|
|
10
|
+
choices = set(choices or [])
|
|
11
|
+
|
|
12
|
+
while True:
|
|
13
|
+
data = input(f'{hint}: ')
|
|
14
|
+
data = data.strip()
|
|
15
|
+
if not data or (choices and data not in choices):
|
|
16
|
+
continue
|
|
17
|
+
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
parser = argparse.ArgumentParser(prog='webinardump')
|
|
23
|
+
parser.add_argument('-t', '--target', type=Path, default=Path(), help='Directory to dump to')
|
|
24
|
+
parser.add_argument('--timeout', type=int, default=3, help='Request timeout')
|
|
25
|
+
parser.add_argument('--rmax', type=int, default=10, help='Max concurrent requests number')
|
|
26
|
+
parser.add_argument('--debug', help='Show debug information', action='store_true')
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO, format='%(levelname)-8s: %(message)s')
|
|
31
|
+
|
|
32
|
+
dumper_choices = []
|
|
33
|
+
print('Available dumpers:')
|
|
34
|
+
|
|
35
|
+
for idx, dumper in enumerate(Dumper.registry, 1):
|
|
36
|
+
print(f'{idx} — {dumper.title}')
|
|
37
|
+
dumper_choices.append(f'{idx}')
|
|
38
|
+
|
|
39
|
+
chosen = get_user_input('', 'Select dumper number', choices=dumper_choices)
|
|
40
|
+
|
|
41
|
+
dumper = Dumper.registry[int(chosen)-1](
|
|
42
|
+
target_dir=args.target,
|
|
43
|
+
timeout=args.timeout,
|
|
44
|
+
concurrent=args.rmax,
|
|
45
|
+
)
|
|
46
|
+
dumper.run(get_user_input)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from contextlib import chdir
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from random import choice
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from time import sleep
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from requests import Session
|
|
13
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
14
|
+
|
|
15
|
+
from ..utils import LOGGER, call
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Dumper:
|
|
19
|
+
|
|
20
|
+
title: str = ''
|
|
21
|
+
|
|
22
|
+
_user_input_map: ClassVar[dict[str, str]]
|
|
23
|
+
|
|
24
|
+
_headers: ClassVar[dict[str, str]] = {
|
|
25
|
+
'Connection': 'keep-alive',
|
|
26
|
+
'Accept': '*/*',
|
|
27
|
+
'User-Agent': (
|
|
28
|
+
'Mozilla/5.0 (X11; Linux x86_64) '
|
|
29
|
+
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
30
|
+
'Chrome/79.0.3945.136 YaBrowser/20.2.3.320 (beta) Yowser/2.5 Safari/537.36'
|
|
31
|
+
),
|
|
32
|
+
'Sec-Fetch-Site': 'same-site',
|
|
33
|
+
'Sec-Fetch-Mode': 'cors',
|
|
34
|
+
'Accept-Language': 'ru,en;q=0.9',
|
|
35
|
+
'Accept-Encoding': 'gzip, deflate, sdch, br',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
registry: ClassVar[list[type['Dumper']]] = []
|
|
39
|
+
|
|
40
|
+
def __init_subclass__(cls):
|
|
41
|
+
super().__init_subclass__()
|
|
42
|
+
cls.registry.append(cls)
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, target_dir: Path, timeout: int = 3, concurrent: int = 10, sleepy: bool = False) -> None:
|
|
45
|
+
self._target_dir = target_dir
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._concurrent = concurrent
|
|
48
|
+
self._user_input_map = self._user_input_map or {}
|
|
49
|
+
self._session = self._get_session()
|
|
50
|
+
self._sleepy = sleepy
|
|
51
|
+
|
|
52
|
+
def __str__(self):
|
|
53
|
+
return self.title
|
|
54
|
+
|
|
55
|
+
def _get_session(self) -> Session:
|
|
56
|
+
# todo при ошибках сессия в нитях блокируется. можно попробовать несколько сессий
|
|
57
|
+
session = requests.Session()
|
|
58
|
+
session.headers = self._headers
|
|
59
|
+
retries = Retry(total=3, backoff_factor=0.1, status_forcelist=[500])
|
|
60
|
+
session.mount('http://', HTTPAdapter(max_retries=retries))
|
|
61
|
+
session.mount('https://', HTTPAdapter(max_retries=retries))
|
|
62
|
+
return session
|
|
63
|
+
|
|
64
|
+
def _get_args(self, *, get_param_hook: Callable[[str, str], str]) -> dict:
|
|
65
|
+
input_data = {}
|
|
66
|
+
|
|
67
|
+
for param, hint in self._user_input_map.items():
|
|
68
|
+
input_data[param] = get_param_hook(param, hint)
|
|
69
|
+
|
|
70
|
+
return input_data
|
|
71
|
+
|
|
72
|
+
def _chunks_get_list(self, url: str) -> list[str]:
|
|
73
|
+
"""Get video chunks names from playlist file at URL.
|
|
74
|
+
|
|
75
|
+
:param url: File URL.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
LOGGER.info(f'Getting video chunks from playlist {url} ...')
|
|
79
|
+
|
|
80
|
+
playlist = self._get_response_simple(url)
|
|
81
|
+
chunk_lists = []
|
|
82
|
+
|
|
83
|
+
for line in playlist.splitlines():
|
|
84
|
+
line = line.strip()
|
|
85
|
+
|
|
86
|
+
if not line.partition('?')[0].endswith('.ts'):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
chunk_lists.append(line)
|
|
90
|
+
|
|
91
|
+
assert chunk_lists, 'No .ts chunks found in playlist file'
|
|
92
|
+
|
|
93
|
+
return chunk_lists
|
|
94
|
+
|
|
95
|
+
def _chunks_download(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
url_video_root: str,
|
|
99
|
+
dump_dir: Path,
|
|
100
|
+
chunk_names: list[str],
|
|
101
|
+
start_chunk: str,
|
|
102
|
+
headers: dict[str, str] | None = None,
|
|
103
|
+
concurrent: int = 10,
|
|
104
|
+
) -> None:
|
|
105
|
+
|
|
106
|
+
chunks_total = len(chunk_names)
|
|
107
|
+
|
|
108
|
+
progress_file = (dump_dir / 'files.txt')
|
|
109
|
+
progress_file.touch()
|
|
110
|
+
|
|
111
|
+
files_done = dict.fromkeys(progress_file.read_text().splitlines())
|
|
112
|
+
lock = Lock()
|
|
113
|
+
|
|
114
|
+
def dump(*, name: str, url: str, session: Session, sleepy: bool, timeout: int) -> None:
|
|
115
|
+
|
|
116
|
+
name = name.partition('?')[0]
|
|
117
|
+
|
|
118
|
+
if name in files_done:
|
|
119
|
+
LOGGER.info(f'File {name} has been already downloaded before. Skipping.')
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
with session.get(url, headers=headers or {}, stream=True, timeout=timeout) as r:
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
with (dump_dir / name).open('wb') as f:
|
|
125
|
+
f.writelines(r.iter_content(chunk_size=8192))
|
|
126
|
+
|
|
127
|
+
files_done[name] = True
|
|
128
|
+
with lock:
|
|
129
|
+
progress_file.write_text('\n'.join(files_done))
|
|
130
|
+
|
|
131
|
+
if sleepy:
|
|
132
|
+
sleep(choice([1, 0.5, 0.7, 0.6]))
|
|
133
|
+
|
|
134
|
+
with ThreadPoolExecutor(max_workers=concurrent) as executor:
|
|
135
|
+
|
|
136
|
+
future_url_map = {}
|
|
137
|
+
|
|
138
|
+
for chunk_name in chunk_names:
|
|
139
|
+
|
|
140
|
+
if chunk_name == start_chunk:
|
|
141
|
+
start_chunk = '' # clear to allow further download
|
|
142
|
+
|
|
143
|
+
if start_chunk:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
chunk_url = f'{url_video_root.rstrip("/")}/{chunk_name}'
|
|
147
|
+
submitted = executor.submit(
|
|
148
|
+
dump,
|
|
149
|
+
name=chunk_name,
|
|
150
|
+
url=chunk_url,
|
|
151
|
+
session=self._session,
|
|
152
|
+
sleepy=self._sleepy,
|
|
153
|
+
timeout=self._timeout,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
future_url_map[submitted] = (chunk_name, chunk_url)
|
|
157
|
+
|
|
158
|
+
if future_url_map:
|
|
159
|
+
LOGGER.info(f'Downloading up to {concurrent} files concurrently ...')
|
|
160
|
+
|
|
161
|
+
counter = 1
|
|
162
|
+
for future in as_completed(future_url_map):
|
|
163
|
+
chunk_name, chunk_url = future_url_map[future]
|
|
164
|
+
future.result()
|
|
165
|
+
percent = round(counter * 100 / chunks_total, 1)
|
|
166
|
+
counter += 1
|
|
167
|
+
LOGGER.info(f'Got {counter}/{chunks_total} ({chunk_name.partition("?")[0]}) [{percent}%] ...')
|
|
168
|
+
|
|
169
|
+
def _video_concat(self, path: Path) -> Path:
|
|
170
|
+
|
|
171
|
+
LOGGER.info('Concatenating video ...')
|
|
172
|
+
|
|
173
|
+
fname_video = 'all_chunks.mp4'
|
|
174
|
+
fname_index = 'all_chunks.txt'
|
|
175
|
+
|
|
176
|
+
call(f'for i in `ls *.ts | sort -V`; do echo "file $i"; done >> {fname_index}', path=path)
|
|
177
|
+
call(f'ffmpeg -f concat -i {fname_index} -c copy -bsf:a aac_adtstoasc {fname_video}', path=path)
|
|
178
|
+
|
|
179
|
+
return path / fname_video
|
|
180
|
+
|
|
181
|
+
def _get_response_simple(self, url: str, *, json: bool = False) -> str | dict:
|
|
182
|
+
"""Returns a text or a dictionary from a URL.
|
|
183
|
+
|
|
184
|
+
:param url:
|
|
185
|
+
:param json:
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
response = self._session.get(url)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
|
|
191
|
+
if json:
|
|
192
|
+
return response.json()
|
|
193
|
+
|
|
194
|
+
return response.text
|
|
195
|
+
|
|
196
|
+
def _video_dump(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
title: str,
|
|
200
|
+
url_playlist: str,
|
|
201
|
+
url_referer: str,
|
|
202
|
+
start_chunk: str = '',
|
|
203
|
+
) -> Path:
|
|
204
|
+
assert url_playlist.endswith('m3u8'), f'No playlist in `{url_playlist}`'
|
|
205
|
+
|
|
206
|
+
LOGGER.info(f'Title: {title}')
|
|
207
|
+
|
|
208
|
+
chunk_names = self._chunks_get_list(url_playlist)
|
|
209
|
+
|
|
210
|
+
target_dir = self._target_dir
|
|
211
|
+
LOGGER.info(f'Downloading video into {target_dir} ...')
|
|
212
|
+
|
|
213
|
+
with chdir(target_dir):
|
|
214
|
+
dump_dir = (target_dir / title).absolute()
|
|
215
|
+
dump_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
url_root = url_playlist.rpartition('/')[0] # strip playlist filename
|
|
218
|
+
|
|
219
|
+
self._chunks_download(
|
|
220
|
+
url_video_root=url_root,
|
|
221
|
+
dump_dir=dump_dir,
|
|
222
|
+
chunk_names=chunk_names,
|
|
223
|
+
start_chunk=start_chunk,
|
|
224
|
+
headers={'Referer': url_referer.strip()},
|
|
225
|
+
concurrent=self._concurrent,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
fpath_video_target = Path(f'{title}.mp4').absolute()
|
|
229
|
+
fpath_video = self._video_concat(dump_dir)
|
|
230
|
+
|
|
231
|
+
shutil.move(fpath_video, fpath_video_target)
|
|
232
|
+
shutil.rmtree(dump_dir, ignore_errors=True)
|
|
233
|
+
|
|
234
|
+
LOGGER.info(f'Video is ready: {fpath_video_target}')
|
|
235
|
+
return fpath_video_target
|
|
236
|
+
|
|
237
|
+
def _gather(self, *, url_video: str, start_chunk: str = '', **params) -> Path:
|
|
238
|
+
raise NotImplementedError
|
|
239
|
+
|
|
240
|
+
def run(self, params_or_hook: Callable[[str, str], str] | dict[str, str]) -> Path:
|
|
241
|
+
params = params_or_hook if isinstance(params_or_hook, dict) else self._get_args(get_param_hook=params_or_hook)
|
|
242
|
+
return self._gather(**params)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ..utils import LOGGER
|
|
5
|
+
from .base import Dumper
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebinarRu(Dumper):
|
|
9
|
+
|
|
10
|
+
title = 'webinar.ru'
|
|
11
|
+
|
|
12
|
+
_user_input_map: ClassVar[dict[str, str]] = {
|
|
13
|
+
'url_video': 'Video URL (with `record-new/`)',
|
|
14
|
+
'url_playlist': 'Video chunk list URL (with `chunklist.m3u8`)',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_headers: ClassVar[dict[str, str]] = {
|
|
18
|
+
**Dumper._headers,
|
|
19
|
+
'Origin': 'https://events.webinar.ru',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def _gather(self, *, url_video: str, start_chunk: str = '', url_playlist: str = '', **params) -> Path:
|
|
23
|
+
"""Runs video dump.
|
|
24
|
+
|
|
25
|
+
:param url_video: Video URL. Hint: has record-new/
|
|
26
|
+
:param url_playlist: Video chunk list URL. Hint: ends with chunklist.m3u8
|
|
27
|
+
:param start_chunk: Optional chunk name to continue download from.
|
|
28
|
+
"""
|
|
29
|
+
assert url_playlist, 'Playlist URL must be specified'
|
|
30
|
+
|
|
31
|
+
assert 'record-new/' in url_video, (
|
|
32
|
+
'Unexpected video URL format\n'
|
|
33
|
+
f'Given: {url_video}.\n'
|
|
34
|
+
f'Expected: https://events.webinar.ru/xxx/yyy/record-new/aaa/bbb')
|
|
35
|
+
|
|
36
|
+
_, _, tail = url_video.partition('record-new/')
|
|
37
|
+
session_id, _, video_id = tail.partition('/')
|
|
38
|
+
|
|
39
|
+
LOGGER.info('Getting manifest ...')
|
|
40
|
+
|
|
41
|
+
manifest = self._get_response_simple(
|
|
42
|
+
f'https://events.webinar.ru/api/eventsessions/{session_id}/record/isviewable?recordAccessToken={video_id}',
|
|
43
|
+
json=True
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return self._video_dump(
|
|
47
|
+
title=manifest['name'],
|
|
48
|
+
url_playlist=url_playlist,
|
|
49
|
+
url_referer=url_video,
|
|
50
|
+
start_chunk=start_chunk,
|
|
51
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from ..utils import LOGGER
|
|
7
|
+
from .base import Dumper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class YandexDisk(Dumper):
|
|
11
|
+
|
|
12
|
+
title = 'Яндекс.Диск'
|
|
13
|
+
|
|
14
|
+
_user_input_map: ClassVar[dict[str, str]] = {
|
|
15
|
+
'url_video': 'Video URL (https://disk.yandex.ru/i/xxx)',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def _get_manifest(self, url: str) -> dict:
|
|
19
|
+
LOGGER.debug(f'Getting manifest from {url} ...')
|
|
20
|
+
|
|
21
|
+
contents = self._get_response_simple(url)
|
|
22
|
+
manifest = re.findall(r'id="store-prefetch">([^<]+)</script', contents)
|
|
23
|
+
assert manifest, f'Manifest not found for {url}'
|
|
24
|
+
manifest = manifest[0]
|
|
25
|
+
manifest = json.loads(manifest)
|
|
26
|
+
return manifest
|
|
27
|
+
|
|
28
|
+
def _get_playlist_and_title(self, manifest: dict) -> tuple[str, str]:
|
|
29
|
+
|
|
30
|
+
resources = list(manifest['resources'].values())
|
|
31
|
+
resource = resources[0]
|
|
32
|
+
|
|
33
|
+
dimension_max = 0
|
|
34
|
+
url_playlist = '<none>'
|
|
35
|
+
|
|
36
|
+
for stream_info in resource['videoStreams']['videos']:
|
|
37
|
+
dimension, *_ = stream_info['dimension'].partition('p')
|
|
38
|
+
if not dimension.isnumeric():
|
|
39
|
+
continue # e.g. 'adaptive'
|
|
40
|
+
dimension = int(dimension)
|
|
41
|
+
if dimension_max < dimension:
|
|
42
|
+
dimension_max = dimension
|
|
43
|
+
url_playlist = stream_info['url']
|
|
44
|
+
|
|
45
|
+
return url_playlist, resource['name']
|
|
46
|
+
|
|
47
|
+
def _gather(self, *, url_video: str, start_chunk: str = '', **params) -> Path:
|
|
48
|
+
|
|
49
|
+
manifest = self._get_manifest(url_video)
|
|
50
|
+
url_playlist, title = self._get_playlist_and_title(manifest)
|
|
51
|
+
|
|
52
|
+
return self._video_dump(
|
|
53
|
+
title=title,
|
|
54
|
+
url_playlist=url_playlist,
|
|
55
|
+
url_referer=url_video,
|
|
56
|
+
start_chunk=start_chunk,
|
|
57
|
+
)
|