rottenstuff commited on
Commit
d81e309
·
verified ·
1 Parent(s): 9ccf1d9

Upload 5 files

Browse files
colab_tunnel/__init__.py CHANGED
@@ -1,3 +1,4 @@
 
1
  """
2
  colab-proxy — утилиты для туннелирования веб-интерфейсов в Google Colab.
3
 
@@ -16,6 +17,9 @@ from ._tunnels import (
16
  get_localt_url,
17
  get_gradio_url,
18
  get_native_url,
 
 
 
19
  )
20
 
21
  __version__ = '1.0.0'
@@ -29,6 +33,9 @@ __all__ = [
29
  'get_localt_url',
30
  'get_gradio_url',
31
  'get_native_url',
 
 
 
32
  'download',
33
  'run',
34
  'WORK_FOLDER',
@@ -41,6 +48,9 @@ proxies_functions: dict = {
41
  'localt': get_localt_url,
42
  'gradio': get_gradio_url,
43
  'native': get_native_url,
 
 
 
44
  }
45
 
46
 
@@ -61,7 +71,8 @@ def get_share_link(host: str, port: int) -> str:
61
 
62
  Args:
63
  host: Имя провайдера: 'cloudflared', 'tmole', 'tunwg',
64
- 'localt', 'gradio', 'native' — или 'all' для всех сразу.
 
65
  port: Локальный порт веб-интерфейса.
66
 
67
  Returns:
 
1
+ # ./colab_tunnel/__init__.py
2
  """
3
  colab-proxy — утилиты для туннелирования веб-интерфейсов в Google Colab.
4
 
 
17
  get_localt_url,
18
  get_gradio_url,
19
  get_native_url,
20
+ get_mmar_url,
21
+ get_tunnelite_url,
22
+ get_beeceptor_url,
23
  )
24
 
25
  __version__ = '1.0.0'
 
33
  'get_localt_url',
34
  'get_gradio_url',
35
  'get_native_url',
36
+ 'get_mmar_url',
37
+ 'get_tunnelite_url',
38
+ 'get_beeceptor_url',
39
  'download',
40
  'run',
41
  'WORK_FOLDER',
 
48
  'localt': get_localt_url,
49
  'gradio': get_gradio_url,
50
  'native': get_native_url,
51
+ 'mmar': get_mmar_url,
52
+ 'tunnelite': get_tunnelite_url,
53
+ 'beeceptor': get_beeceptor_url,
54
  }
55
 
56
 
 
71
 
72
  Args:
73
  host: Имя провайдера: 'cloudflared', 'tmole', 'tunwg',
74
+ 'localt', 'gradio', 'native', 'mmar', 'tunnelite',
75
+ 'beeceptor' — или 'all' для всех сразу.
76
  port: Локальный порт веб-интерфейса.
77
 
78
  Returns:
colab_tunnel/_config.py CHANGED
@@ -1,14 +1,18 @@
 
1
  from os import environ
2
  from pathlib import Path
3
 
4
  # Переопределяется переменной окружения colab_tunnel_WORK_DIR
5
  WORK_FOLDER = Path(environ.get('TUNNEL_WORK_DIR', '/content/.config'))
6
 
7
- claudflare_bin = WORK_FOLDER / 'claudflared'
8
  tmole_bin = WORK_FOLDER / 'tmole'
9
  tunwg_bin = WORK_FOLDER / 'tunwg'
10
  go_localt_bin = WORK_FOLDER / 'go_localt'
11
  gradio_bin = WORK_FOLDER / 'frpc_linux_amd64'
 
 
 
12
  colab_native_url = WORK_FOLDER / 'colab_url.txt'
13
  links_file = WORK_FOLDER / 'links.txt'
14
 
 
1
+ # ./colab_tunnel/_config.py
2
  from os import environ
3
  from pathlib import Path
4
 
5
  # Переопределяется переменной окружения colab_tunnel_WORK_DIR
6
  WORK_FOLDER = Path(environ.get('TUNNEL_WORK_DIR', '/content/.config'))
7
 
8
+ cloudflare_bin = WORK_FOLDER / 'cloudflared'
9
  tmole_bin = WORK_FOLDER / 'tmole'
10
  tunwg_bin = WORK_FOLDER / 'tunwg'
11
  go_localt_bin = WORK_FOLDER / 'go_localt'
12
  gradio_bin = WORK_FOLDER / 'frpc_linux_amd64'
13
+ mmar_bin = WORK_FOLDER / 'mmar'
14
+ tunnelite_bin = WORK_FOLDER / 'tunnelite'
15
+ beeceptor_bin = WORK_FOLDER / 'beeceptor-cli'
16
  colab_native_url = WORK_FOLDER / 'colab_url.txt'
17
  links_file = WORK_FOLDER / 'links.txt'
18
 
colab_tunnel/_ssh.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from atexit import register as exit_register
2
+ import os
3
+ from pathlib import Path
4
+ from re import search
5
+ from select import select
6
+ from subprocess import DEVNULL, PIPE, Popen
7
+ import time
8
+
9
+ from colab_tunnel._utils import terminate_process, run
10
+
11
+ # Глобальный реестр для предотвращения Garbage Collection (сборки мусора) процессов и их труб
12
+ _active_tunnels: list[Popen] = []
13
+
14
+
15
+ def ensure_ssh_key() -> None:
16
+ """
17
+ Гарантирует наличие приватного SSH-ключа в системе.
18
+ """
19
+ ssh_dir = Path.home() / '.ssh'
20
+ ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
21
+ key_file = ssh_dir / 'id_ed25519'
22
+
23
+ if not key_file.exists():
24
+ import subprocess
25
+ subprocess.run(
26
+ ['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key_file)],
27
+ stdout=DEVNULL,
28
+ stderr=DEVNULL
29
+ )
30
+
31
+
32
+ def get_ssh_tunnel_url(
33
+ start_commands: list[str],
34
+ process_name: str,
35
+ url_pattern: str,
36
+ timeout: float = 15.0,
37
+ ) -> str:
38
+ """
39
+ Запускает SSH-процесс с открытым stdin=PIPE для поддержания соединения.
40
+ """
41
+ ensure_ssh_key()
42
+
43
+ # Завершаем старые процессы
44
+ run(f'pkill -f "{process_name}"')
45
+
46
+ # Важно: stdin=PIPE оставляем ОТКРЫТЫМ, чтобы SSH не получал EOF и не выходил
47
+ process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=PIPE)
48
+
49
+ stdout_raw = process.stdout.raw
50
+ stderr_raw = process.stderr.raw
51
+
52
+ # Делаем сырые дескрипторы неблокирующими
53
+ for raw_stream in (stdout_raw, stderr_raw):
54
+ try:
55
+ os.set_blocking(raw_stream.fileno(), False)
56
+ except Exception:
57
+ pass
58
+
59
+ start_time = time.time()
60
+ buffer = b""
61
+
62
+ while time.time() - start_time < timeout:
63
+ ready_readers, _, _ = select([stdout_raw, stderr_raw], [], [], 0.1)
64
+
65
+ read_any = False
66
+ for reader in ready_readers:
67
+ try:
68
+ chunk = reader.read(1024)
69
+ if chunk:
70
+ buffer += chunk
71
+ read_any = True
72
+ except (BlockingIOError, ValueError):
73
+ pass
74
+ except Exception:
75
+ pass
76
+
77
+ decoded_text = buffer.decode('utf-8', errors='ignore')
78
+ match = search(url_pattern, decoded_text)
79
+ if match:
80
+ # Регистрируем завершение при выходе из Python
81
+ exit_register(terminate_process, process_name, process)
82
+ # Добавляем в глобальный список, чтобы сохранить процесс и stdin-трубу открытыми
83
+ _active_tunnels.append(process)
84
+ return match.group()
85
+
86
+ if not read_any and process.poll() is not None:
87
+ break
88
+
89
+ time.sleep(0.05)
90
+
91
+ process.terminate()
92
+ run(f'pkill -f "{process_name}"')
93
+
94
+ final_output = buffer.decode('utf-8', errors='ignore')
95
+ raise RuntimeError(
96
+ f'Не удалось получить SSH-ссылку за {timeout} сек. Вывод процесса:\n{final_output}'
97
+ )
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Провайдеры
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def get_optimistix_url(port: int) -> str:
105
+ cmd = [
106
+ 'ssh',
107
+ '-o', 'StrictHostKeyChecking=no',
108
+ '-o', 'UserKnownHostsFile=/dev/null',
109
+ '-o', 'ExitOnForwardFailure=yes',
110
+ '-o', 'ServerAliveInterval=10',
111
+ '-o', 'ServerAliveCountMax=3',
112
+ '-p', '1122',
113
+ '-R', f'80:127.0.0.1:{port}',
114
+ 'ssh.optimistixtunnel.com'
115
+ ]
116
+ return get_ssh_tunnel_url(
117
+ start_commands=cmd,
118
+ process_name='ssh.optimistixtunnel.com',
119
+ url_pattern=r'https://\S+\.otnl\.link',
120
+ )
121
+
122
+
123
+ def get_srvus_url(port: int) -> str:
124
+ cmd = [
125
+ 'ssh',
126
+ '-o', 'StrictHostKeyChecking=no',
127
+ '-o', 'UserKnownHostsFile=/dev/null',
128
+ '-o', 'ExitOnForwardFailure=yes',
129
+ '-o', 'ServerAliveInterval=10',
130
+ '-o', 'ServerAliveCountMax=3',
131
+ '-R', f'1:127.0.0.1:{port}',
132
+ 'srv.us'
133
+ ]
134
+ return get_ssh_tunnel_url(
135
+ start_commands=cmd,
136
+ process_name='srv.us',
137
+ url_pattern=r'https://[a-z0-9]{10,}\.srv\.us/?',
138
+ )
139
+
140
+
141
+ def get_serveo_url(port: int) -> str:
142
+ cmd = [
143
+ 'ssh',
144
+ '-o', 'StrictHostKeyChecking=no',
145
+ '-o', 'UserKnownHostsFile=/dev/null',
146
+ '-o', 'ExitOnForwardFailure=yes',
147
+ '-o', 'ServerAliveInterval=10',
148
+ '-o', 'ServerAliveCountMax=3',
149
+ '-R', f'80:127.0.0.1:{port}',
150
+ 'serveo.net'
151
+ ]
152
+ return get_ssh_tunnel_url(
153
+ start_commands=cmd,
154
+ process_name='serveo.net',
155
+ url_pattern=r'https://\S+\.serveousercontent\.com',
156
+ )
157
+
158
+
159
+ def get_localhostrun_url(port: int) -> str:
160
+ cmd = [
161
+ 'ssh',
162
+ '-o', 'StrictHostKeyChecking=no',
163
+ '-o', 'UserKnownHostsFile=/dev/null',
164
+ '-o', 'ExitOnForwardFailure=yes',
165
+ '-o', 'ServerAliveInterval=10',
166
+ '-o', 'ServerAliveCountMax=3',
167
+ '-R', f'80:127.0.0.1:{port}',
168
+ 'nokey@localhost.run'
169
+ ]
170
+ return get_ssh_tunnel_url(
171
+ start_commands=cmd,
172
+ process_name='localhost.run',
173
+ url_pattern=r'https://(?!admin\b)[a-zA-Z0-9-]+\.(?:localhost\.run|lhr\.life)',
174
+ )
175
+
colab_tunnel/_tunnels.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from atexit import register as exit_register
2
  from pathlib import Path
3
  from re import search
@@ -8,8 +9,9 @@ from requests import get as get_url
8
 
9
  from ._config import (
10
  WORK_FOLDER,
11
- claudflare_bin, tmole_bin, tunwg_bin,
12
- go_localt_bin, gradio_bin, links_file,
 
13
  )
14
  from ._utils import (
15
  download, unpack_archive, run,
@@ -27,24 +29,31 @@ def get_revproxy_url(
27
  lines_to_read: int,
28
  url_pattern: str = r'https://\S+',
29
  write_link: bool = False,
 
30
  ) -> str:
31
  if not bin_path.exists():
32
- files_before = {f.name for f in WORK_FOLDER.iterdir() if f.is_file()}
33
 
34
  if need_unpack:
35
  unpack_archive(download(bin_url, progress=False), WORK_FOLDER, rm_archive=True)
36
  else:
37
  download(bin_url, save_path=bin_path.parent, progress=False)
38
 
39
- files_after = {f.name for f in WORK_FOLDER.iterdir() if f.is_file()}
40
- new_files = files_after - files_before
41
 
42
  if len(new_files) == 1:
43
- move_path(WORK_FOLDER / new_files.pop(), bin_path)
 
 
 
 
 
 
44
  else:
45
  raise RuntimeError(
46
  f'ошибка при определении нового файла после распаковки!\n'
47
- f'ожидался один новый файл, а появились: {", ".join(new_files)}'
48
  )
49
 
50
  bin_path.chmod(0o777)
@@ -52,7 +61,14 @@ def get_revproxy_url(
52
  if is_process_running(bin_path.name):
53
  run(f'pkill -f {bin_path.name}')
54
 
55
- process = Popen(start_commands, stdout=PIPE, stderr=PIPE, text=True)
 
 
 
 
 
 
 
56
  stream = process.stderr if read_from_stderr else process.stdout
57
  lines: list[str] = []
58
 
@@ -107,8 +123,8 @@ def get_cloudflared_url(port: int) -> str:
107
  return get_revproxy_url(
108
  bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
109
  need_unpack=False,
110
- bin_path=claudflare_bin,
111
- start_commands=[str(claudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
112
  read_from_stderr=True,
113
  lines_to_read=6,
114
  url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
@@ -160,6 +176,43 @@ def get_gradio_url(port: int) -> str:
160
  raise RuntimeError(f'после {max_attempts} попыток поднять туннель градио не удалось: {last_error}')
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def get_native_url(port: int) -> str:
164
  try:
165
  from google.colab.output import eval_js # type: ignore[import]
 
1
+ # ./colab_tunnel/_tunnels.py
2
  from atexit import register as exit_register
3
  from pathlib import Path
4
  from re import search
 
9
 
10
  from ._config import (
11
  WORK_FOLDER,
12
+ cloudflare_bin, tmole_bin, tunwg_bin,
13
+ go_localt_bin, gradio_bin, mmar_bin,
14
+ tunnelite_bin, beeceptor_bin, links_file
15
  )
16
  from ._utils import (
17
  download, unpack_archive, run,
 
29
  lines_to_read: int,
30
  url_pattern: str = r'https://\S+',
31
  write_link: bool = False,
32
+ stdin_input: str | None = None,
33
  ) -> str:
34
  if not bin_path.exists():
35
+ files_before = {f for f in WORK_FOLDER.iterdir() if f.is_file()}
36
 
37
  if need_unpack:
38
  unpack_archive(download(bin_url, progress=False), WORK_FOLDER, rm_archive=True)
39
  else:
40
  download(bin_url, save_path=bin_path.parent, progress=False)
41
 
42
+ files_after = {f for f in WORK_FOLDER.iterdir() if f.is_file()}
43
+ new_files = list(files_after - files_before)
44
 
45
  if len(new_files) == 1:
46
+ move_path(new_files[0], bin_path)
47
+ elif len(new_files) > 1:
48
+ largest_file = max(new_files, key=lambda f: f.stat().st_size)
49
+ move_path(largest_file, bin_path)
50
+ for f in new_files:
51
+ if f != largest_file:
52
+ f.unlink(missing_ok=True)
53
  else:
54
  raise RuntimeError(
55
  f'ошибка при определении нового файла после распаковки!\n'
56
+ f'ожидалось появление новых файлов в директории {WORK_FOLDER}'
57
  )
58
 
59
  bin_path.chmod(0o777)
 
61
  if is_process_running(bin_path.name):
62
  run(f'pkill -f {bin_path.name}')
63
 
64
+ # Если требуется отправка ввода, открываем канал stdin
65
+ stdin_channel = PIPE if stdin_input is not None else None
66
+ process = Popen(start_commands, stdout=PIPE, stderr=PIPE, stdin=stdin_channel, text=True)
67
+
68
+ if stdin_input is not None and process.stdin:
69
+ process.stdin.write(stdin_input)
70
+ process.stdin.flush()
71
+
72
  stream = process.stderr if read_from_stderr else process.stdout
73
  lines: list[str] = []
74
 
 
123
  return get_revproxy_url(
124
  bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
125
  need_unpack=False,
126
+ bin_path=cloudflare_bin,
127
+ start_commands=[str(cloudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
128
  read_from_stderr=True,
129
  lines_to_read=6,
130
  url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
 
176
  raise RuntimeError(f'после {max_attempts} попыток поднять туннель градио не удалось: {last_error}')
177
 
178
 
179
+ def get_mmar_url(port: int) -> str:
180
+ return get_revproxy_url(
181
+ bin_url='https://github.com/yusuf-musleh/mmar/releases/latest/download/mmar_Linux_x86_64.tar.gz',
182
+ need_unpack=True,
183
+ bin_path=mmar_bin,
184
+ start_commands=[str(mmar_bin), 'client', '--local-port', str(port)],
185
+ read_from_stderr=True,
186
+ lines_to_read=10,
187
+ url_pattern=r'(?P<url>https?://\S+\.mmar\.dev)',
188
+ )
189
+
190
+
191
+ def get_tunnelite_url(port: int) -> str:
192
+ return get_revproxy_url(
193
+ bin_url='https://github.com/hehepiska/tunnellite-linux/releases/download/latest/tunnelite',
194
+ need_unpack=False,
195
+ bin_path=tunnelite_bin,
196
+ start_commands=[str(tunnelite_bin), f'http://127.0.0.1:{port}'],
197
+ read_from_stderr=False,
198
+ lines_to_read=60,
199
+ url_pattern=r'(?P<url>https?://\S+\.tunnelite\.com)',
200
+ )
201
+
202
+
203
+ def get_beeceptor_url(port: int) -> str:
204
+ return get_revproxy_url(
205
+ bin_url='https://cdn.beeceptor.com/downloads/cli/latest/beeceptor-cli-linux-x64.tar.gz',
206
+ need_unpack=True,
207
+ bin_path=beeceptor_bin,
208
+ start_commands=[str(beeceptor_bin), '-p', str(port), '-y'],
209
+ read_from_stderr=False,
210
+ lines_to_read=15,
211
+ url_pattern=r'(?P<url>https?://\S+\.beeceptor\.com)',
212
+ stdin_input='\n', # Симулируем нажатие клавиши Enter для выбора первого пункта
213
+ )
214
+
215
+
216
  def get_native_url(port: int) -> str:
217
  try:
218
  from google.colab.output import eval_js # type: ignore[import]
colab_tunnel/_utils.py CHANGED
@@ -149,7 +149,6 @@ def terminate_process(process_name: str, process_obj: Popen) -> None:
149
 
150
  def move_path(old_path: Path | str, new_path: Path | str) -> None:
151
  old, new = Path(old_path), Path(new_path)
152
- # BUGFIX: оригинал проверял old_path.exists() — падало если old_path это str
153
  if not old.exists():
154
  raise RuntimeError(f'не найден исходный путь для перемещения: {old}')
155
  try:
@@ -175,15 +174,19 @@ def is_ipv4(address: str) -> bool:
175
  return bool(pattern.match(address))
176
 
177
 
178
- def is_valid_url(url: str) -> bool:
 
 
 
 
179
  for attempt in range(2):
180
  try:
181
- with get_url(url, headers=HEADERS, allow_redirects=True, stream=True, timeout=10) as resp:
182
  if 200 <= resp.status_code < 300:
183
  return True
184
  except Exception:
185
  try:
186
- resp = get_head(url, headers=HEADERS, allow_redirects=True, timeout=10)
187
  if 200 <= resp.status_code < 300:
188
  return True
189
  except Exception:
@@ -214,21 +217,25 @@ def download(
214
  save_path: str | Path | None = None,
215
  progress: bool = True,
216
  ) -> Path:
217
- # Поддержка --header= суффикса
218
  extra_headers: dict | None = None
219
  url_parts = url.replace('"', '').replace("'", '').split('--header=')
220
  if len(url_parts) > 1:
221
  url = url_parts[0].strip()
222
- key, _, value = url_parts[1].partition(':') # BUGFIX: partition вместо split(':')[1]
223
  extra_headers = {key.strip(): value.strip()}
224
 
225
- if not is_valid_url(url):
226
- raise RuntimeError(f'недействительная ссылка на файл: {url}')
 
227
 
228
  save_path = Path(save_path) if save_path else Path.cwd()
229
  save_path.mkdir(parents=True, exist_ok=True)
230
 
231
- with get_url(url, stream=True, allow_redirects=True, headers=extra_headers) as req:
 
 
 
 
232
  file_size = int(req.headers.get('content-length', 0))
233
  file_name = filename or get_filename_from_headers(req.headers) or Path(urlparse(req.url).path).name
234
  file_path = save_path / file_name
@@ -241,7 +248,7 @@ def download(
241
  for chunk in req.iter_content(chunk_size=chunk_size):
242
  if chunk:
243
  fp.write(chunk)
244
- if progress and file_size: # BUGFIX: guard деления на 0
245
  downloaded += len(chunk)
246
  elapsed = time() - start
247
  print(
@@ -252,5 +259,5 @@ def download(
252
  raise RuntimeError(f'не удалось загрузить файл по ссылке {url}:\n{e}')
253
 
254
  if progress and file_size:
255
- print() # перевод строки после прогресс-бара
256
  return file_path
 
149
 
150
  def move_path(old_path: Path | str, new_path: Path | str) -> None:
151
  old, new = Path(old_path), Path(new_path)
 
152
  if not old.exists():
153
  raise RuntimeError(f'не найден исходный путь для перемещения: {old}')
154
  try:
 
174
  return bool(pattern.match(address))
175
 
176
 
177
+ def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
178
+ req_headers = HEADERS.copy()
179
+ if custom_headers:
180
+ req_headers.update(custom_headers)
181
+
182
  for attempt in range(2):
183
  try:
184
+ with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
185
  if 200 <= resp.status_code < 300:
186
  return True
187
  except Exception:
188
  try:
189
+ resp = get_head(url, headers=req_headers, allow_redirects=True, timeout=10)
190
  if 200 <= resp.status_code < 300:
191
  return True
192
  except Exception:
 
217
  save_path: str | Path | None = None,
218
  progress: bool = True,
219
  ) -> Path:
 
220
  extra_headers: dict | None = None
221
  url_parts = url.replace('"', '').replace("'", '').split('--header=')
222
  if len(url_parts) > 1:
223
  url = url_parts[0].strip()
224
+ key, _, value = url_parts[1].partition(':')
225
  extra_headers = {key.strip(): value.strip()}
226
 
227
+ # Проверка валидности с учетом переданных заголовков
228
+ if not is_valid_url(url, custom_headers=extra_headers):
229
+ raise RuntimeError(f'недействительная ссылка на файл или доступ ограничен: {url}')
230
 
231
  save_path = Path(save_path) if save_path else Path.cwd()
232
  save_path.mkdir(parents=True, exist_ok=True)
233
 
234
+ req_headers = HEADERS.copy()
235
+ if extra_headers:
236
+ req_headers.update(extra_headers)
237
+
238
+ with get_url(url, stream=True, allow_redirects=True, headers=req_headers) as req:
239
  file_size = int(req.headers.get('content-length', 0))
240
  file_name = filename or get_filename_from_headers(req.headers) or Path(urlparse(req.url).path).name
241
  file_path = save_path / file_name
 
248
  for chunk in req.iter_content(chunk_size=chunk_size):
249
  if chunk:
250
  fp.write(chunk)
251
+ if progress and file_size:
252
  downloaded += len(chunk)
253
  elapsed = time() - start
254
  print(
 
259
  raise RuntimeError(f'не удалось загрузить файл по ссылке {url}:\n{e}')
260
 
261
  if progress and file_size:
262
+ print()
263
  return file_path