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.
@@ -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
+ [![PyPI - Version](https://img.shields.io/pypi/v/webinardump)](https://pypi.python.org/pypi/webinardump)
18
+ [![License](https://img.shields.io/pypi/l/webinardump)](https://pypi.python.org/pypi/webinardump)
19
+ [![Coverage](https://img.shields.io/coverallsCoverage/github/idlesign/webinardump)](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
+ [![PyPI - Version](https://img.shields.io/pypi/v/webinardump)](https://pypi.python.org/pypi/webinardump)
6
+ [![License](https://img.shields.io/pypi/l/webinardump)](https://pypi.python.org/pypi/webinardump)
7
+ [![Coverage](https://img.shields.io/coverallsCoverage/github/idlesign/webinardump)](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,9 @@
1
+ from .base import Dumper
2
+ from .webinarru import WebinarRu
3
+ from .yadisk import YandexDisk
4
+
5
+ __all__ = [
6
+ 'Dumper',
7
+ 'WebinarRu',
8
+ 'YandexDisk',
9
+ ]
@@ -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
+ )
@@ -0,0 +1,9 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from subprocess import check_call
4
+
5
+ LOGGER = logging.getLogger('webinardump')
6
+
7
+
8
+ def call(cmd: str, *, path: Path):
9
+ return check_call(cmd, cwd=path, shell=True)