hyperdownloader-core 1.0.4__py3-none-any.whl

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,47 @@
1
+ """
2
+ HyperDownloader Core — 高性能多线程下载引擎
3
+ =============================================
4
+ 完全解耦,不依赖任何 GUI 框架,可无缝集成到其他 Python 项目中。
5
+ """
6
+
7
+ from .enums import DownloadStatus, SegmentStatus, SchedulerPolicy
8
+ from .models import (
9
+ DownloadTask,
10
+ DownloadConfig,
11
+ DownloadProgress,
12
+ DownloadSegment,
13
+ DownloadResult,
14
+ ProxyConfig,
15
+ Headers,
16
+ )
17
+ from .utils import format_bytes, format_speed, format_time, get_downloads_folder
18
+ from .core import HyperDownloader
19
+ from .config_manager import AppConfig, load_config, save_config
20
+
21
+ __all__ = [
22
+ # 枚举
23
+ "DownloadStatus",
24
+ "SegmentStatus",
25
+ "SchedulerPolicy",
26
+ # 数据模型
27
+ "DownloadTask",
28
+ "DownloadConfig",
29
+ "DownloadProgress",
30
+ "DownloadSegment",
31
+ "DownloadResult",
32
+ "ProxyConfig",
33
+ "Headers",
34
+ # 核心入口
35
+ "HyperDownloader",
36
+ # 配置管理
37
+ "AppConfig",
38
+ "load_config",
39
+ "save_config",
40
+ # 工具
41
+ "get_downloads_folder",
42
+ "format_bytes",
43
+ "format_speed",
44
+ "format_time",
45
+ ]
46
+
47
+ __version__ = "1.0.4"
@@ -0,0 +1,417 @@
1
+ """
2
+ REST API 服务器 — 直接使用 TaskDownloader,不经过调度器
3
+
4
+ 提供 JSON API 供第三方前端(Web / 桌面 App)接入。
5
+
6
+ 启动:
7
+ python -m hyperdownloader.api_server [--port 8765]
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import sys
15
+ import threading
16
+ import time
17
+ import urllib.parse
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from http.server import HTTPServer, BaseHTTPRequestHandler
20
+ from typing import Optional
21
+
22
+ _project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23
+ if _project_root not in sys.path:
24
+ sys.path.insert(0, _project_root)
25
+
26
+ from hyperdownloader import DownloadTask, DownloadConfig, DownloadStatus
27
+ from hyperdownloader.downloader import TaskDownloader
28
+ from hyperdownloader.utils import get_downloads_folder
29
+ from hyperdownloader.config_manager import load_config
30
+
31
+ logger = logging.getLogger("hyperdownloader.api")
32
+ _STATIC_DIR = os.path.normpath(
33
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tools")
34
+ )
35
+
36
+ # ── 全局任务仓库(支持多任务并发下载)──
37
+ _tasks: dict[str, TaskDownloader] = {} # 所有任务(运行中 + 排队中)
38
+ _tasks_lock = threading.Lock()
39
+ _max_concurrent = 5 # 最大并发下载数
40
+ _pending_queue: list[dict] = [] # 排队等待的任务参数
41
+ _queue_lock = threading.Lock()
42
+ _queue_thread_running = False
43
+
44
+
45
+ def _start_task_directly(url: str, save_dir: str, filename: Optional[str] = None,
46
+ segments: int = 4, expected_sha256: Optional[str] = None) -> str:
47
+ """直接启动一个下载任务(不排队)"""
48
+ config = DownloadConfig(max_segments=segments)
49
+ task = DownloadTask(
50
+ url=url, save_dir=save_dir, filename=filename,
51
+ config=config, expected_sha256=expected_sha256,
52
+ )
53
+ dl = TaskDownloader(task)
54
+ with _tasks_lock:
55
+ _tasks[task.task_id] = dl
56
+ dl.start()
57
+ logger.info("任务已启动: %s [%s]", url, task.task_id)
58
+ return task.task_id
59
+
60
+
61
+ def _try_start_task(url: str, save_dir: str, filename: Optional[str] = None,
62
+ segments: int = 4, expected_sha256: Optional[str] = None) -> tuple[str, bool]:
63
+ """
64
+ 尝试启动任务。如果并发数未满则立即启动,否则加入排队队列。
65
+
66
+ Returns:
67
+ (task_id, 是否立即启动)
68
+ """
69
+ with _tasks_lock:
70
+ running = sum(1 for d in _tasks.values()
71
+ if d.status == DownloadStatus.RUNNING)
72
+ if running < _max_concurrent:
73
+ # 有可用槽位,直接启动
74
+ return _start_task_directly(url, save_dir, filename, segments, expected_sha256), True
75
+
76
+ # 并发已满,加入排队队列
77
+ with _queue_lock:
78
+ _pending_queue.append({
79
+ "url": url, "save_dir": save_dir, "filename": filename,
80
+ "segments": segments, "expected_sha256": expected_sha256,
81
+ })
82
+ logger.info("并发已满,任务排队: %s (运行中 %d)", url, running)
83
+ _ensure_queue_worker()
84
+ return "", False
85
+
86
+
87
+ def _ensure_queue_worker():
88
+ """确保排队调度线程在运行"""
89
+ global _queue_thread_running
90
+ if _queue_thread_running:
91
+ return
92
+ _queue_thread_running = True
93
+ t = threading.Thread(target=_queue_worker, daemon=True, name="api-queue")
94
+ t.start()
95
+
96
+
97
+ def _queue_worker():
98
+ """排队调度线程:监视频道,有空位就取出排队任务"""
99
+ import time
100
+ global _queue_thread_running
101
+ try:
102
+ while True:
103
+ # 检查是否有空位
104
+ with _tasks_lock:
105
+ running = sum(1 for d in _tasks.values()
106
+ if d.status == DownloadStatus.RUNNING)
107
+ # 清理已完成的任务
108
+ _cleanup_finished()
109
+
110
+ if running < _max_concurrent:
111
+ with _queue_lock:
112
+ if _pending_queue:
113
+ item = _pending_queue.pop(0)
114
+ with _tasks_lock:
115
+ new_running = sum(1 for d in _tasks.values()
116
+ if d.status == DownloadStatus.RUNNING)
117
+ if new_running < _max_concurrent:
118
+ _start_task_directly(
119
+ item["url"], item["save_dir"], item["filename"],
120
+ item["segments"], item["expected_sha256"],
121
+ )
122
+ continue
123
+ else:
124
+ # 又被占满了,放回去
125
+ _pending_queue.insert(0, item)
126
+
127
+ if not _pending_queue:
128
+ break # 队列为空,结束线程
129
+
130
+ time.sleep(1)
131
+ finally:
132
+ _queue_thread_running = False
133
+
134
+
135
+ def _cleanup_finished():
136
+ """清理已结束的任务"""
137
+ with _tasks_lock:
138
+ finished = [
139
+ tid for tid, dl in list(_tasks.items())
140
+ if dl.status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED)
141
+ ]
142
+ for tid in finished:
143
+ _tasks.pop(tid, None)
144
+
145
+
146
+ def _snapshot(dl: TaskDownloader) -> dict:
147
+ """获取 TaskDownloader 的快照"""
148
+ p = dl.progress
149
+ return {
150
+ "task_id": p.task_id,
151
+ "url": p.url,
152
+ "filename": os.path.basename(p.file_path),
153
+ "status": p.status.name,
154
+ "progress": p.progress,
155
+ "downloaded": p.downloaded,
156
+ "total_size": p.total_size,
157
+ "speed": round(p.speed, 2),
158
+ "avg_speed": round(p.avg_speed, 2),
159
+ "elapsed": round(p.elapsed, 2),
160
+ "eta": round(p.eta, 2) if p.eta > 0 else 0,
161
+ "segments_total": p.segments_total,
162
+ "segments_completed": p.segments_completed,
163
+ "segments_speed": [round(s, 2) for s in p.segments_speed],
164
+ "error": p.error_message,
165
+ "file_path": p.file_path,
166
+ }
167
+
168
+
169
+ # ═══════════════════════════════════════════════
170
+ # HTTP Handler
171
+ # ═══════════════════════════════════════════════
172
+
173
+ class APIHandler(BaseHTTPRequestHandler):
174
+
175
+ def do_GET(self):
176
+ path = urllib.parse.urlparse(self.path).path.rstrip("/")
177
+ try:
178
+ if path == "/api/tasks":
179
+ self._list_tasks()
180
+ elif path.startswith("/api/tasks/"):
181
+ self._get_task(path)
182
+ elif path == "/api/stats":
183
+ self._stats()
184
+ elif path == "/api/config":
185
+ self._get_config()
186
+ else:
187
+ self._serve_static()
188
+ except Exception as e:
189
+ self._send(500, {"error": str(e)})
190
+
191
+ def do_POST(self):
192
+ path = urllib.parse.urlparse(self.path).path.rstrip("/")
193
+ body = self._read_body()
194
+ if body is None:
195
+ return
196
+ try:
197
+ if path == "/api/tasks":
198
+ self._create_task(body)
199
+ elif path.endswith("/pause"):
200
+ self._pause_task(path)
201
+ elif path.endswith("/resume"):
202
+ self._resume_task(path)
203
+ elif path.endswith("/cancel"):
204
+ self._cancel_task(path)
205
+ else:
206
+ self._send(404, {"error": "Not Found"})
207
+ except Exception as e:
208
+ self._send(500, {"error": str(e)})
209
+
210
+ def do_OPTIONS(self):
211
+ self._cors()
212
+ self.send_response(204)
213
+ self.end_headers()
214
+
215
+ # ── 工具 ──
216
+
217
+ def _read_body(self) -> Optional[dict]:
218
+ length = int(self.headers.get("Content-Length", 0))
219
+ if length <= 0:
220
+ return {}
221
+ try:
222
+ return json.loads(self.rfile.read(length))
223
+ except json.JSONDecodeError:
224
+ self._send(400, {"error": "Invalid JSON"})
225
+ return None
226
+
227
+ def _send(self, status: int, data):
228
+ self.send_response(status)
229
+ self._cors()
230
+ self.send_header("Content-Type", "application/json; charset=utf-8")
231
+ self.end_headers()
232
+ self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode())
233
+
234
+ def _cors(self):
235
+ self.send_header("Access-Control-Allow-Origin", "*")
236
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
237
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
238
+
239
+ def log_message(self, fmt, *args):
240
+ pass # 静默日志,避免刷屏
241
+
242
+ def _task_id_from_path(self, path: str) -> str:
243
+ """从 /api/tasks/{id}/action 提取 task_id"""
244
+ return path.split("/api/tasks/")[1].split("/")[0]
245
+
246
+ # ── API 端点 ──
247
+
248
+ def _list_tasks(self):
249
+ """GET /api/tasks"""
250
+ with _tasks_lock:
251
+ items = [_snapshot(dl) for dl in _tasks.values()]
252
+ with _queue_lock:
253
+ queued = len(_pending_queue)
254
+ running = sum(1 for t in items if t["status"] == "RUNNING")
255
+ pending = sum(1 for t in items if t["status"] == "PENDING")
256
+ paused = sum(1 for t in items if t["status"] == "PAUSED")
257
+ completed = sum(1 for t in items if t["status"] == "COMPLETED")
258
+ self._send(200, {
259
+ "tasks": items,
260
+ "count": len(items),
261
+ "queue": {
262
+ "running": running,
263
+ "pending": pending,
264
+ "paused": paused,
265
+ "completed": completed,
266
+ "queued": queued,
267
+ "max_concurrent": _max_concurrent,
268
+ },
269
+ })
270
+
271
+ def _get_task(self, path: str):
272
+ """GET /api/tasks/{id}"""
273
+ tid = self._task_id_from_path(path)
274
+ with _tasks_lock:
275
+ dl = _tasks.get(tid)
276
+ if dl is None:
277
+ self._send(404, {"error": "Task not found"})
278
+ else:
279
+ self._send(200, _snapshot(dl))
280
+
281
+ def _create_task(self, body: dict):
282
+ """POST /api/tasks"""
283
+ url = body.get("url", "").strip()
284
+ if not url:
285
+ self._send(400, {"error": "Missing 'url' field"})
286
+ return
287
+ save_dir = body.get("save_dir") or get_downloads_folder()
288
+ filename = body.get("filename")
289
+ segments = body.get("max_segments", 4)
290
+ expected_sha256 = body.get("expected_sha256")
291
+ os.makedirs(save_dir, exist_ok=True)
292
+ tid, immediate = _try_start_task(url, save_dir, filename, segments, expected_sha256)
293
+ if immediate:
294
+ self._send(201, {
295
+ "task_id": tid, "url": url,
296
+ "save_dir": save_dir, "filename": filename or "",
297
+ "status": "RUNNING",
298
+ })
299
+ else:
300
+ self._send(202, {
301
+ "url": url,
302
+ "message": "并发已满,任务已排队,有空位时自动开始",
303
+ "status": "QUEUED",
304
+ })
305
+
306
+ def _pause_task(self, path: str):
307
+ tid = self._task_id_from_path(path)
308
+ with _tasks_lock:
309
+ dl = _tasks.get(tid)
310
+ if dl: dl.pause(); self._send(200, {"success": True})
311
+ else: self._send(404, {"error": "Not found"})
312
+
313
+ def _resume_task(self, path: str):
314
+ tid = self._task_id_from_path(path)
315
+ with _tasks_lock:
316
+ dl = _tasks.get(tid)
317
+ if dl: dl.resume(); self._send(200, {"success": True})
318
+ else: self._send(404, {"error": "Not found"})
319
+
320
+ def _cancel_task(self, path: str):
321
+ tid = self._task_id_from_path(path)
322
+ with _tasks_lock:
323
+ dl = _tasks.pop(tid, None)
324
+ if dl: dl.cancel(); self._send(200, {"success": True})
325
+ else: self._send(404, {"error": "Not found"})
326
+
327
+ def _stats(self):
328
+ """GET /api/stats"""
329
+ _cleanup_finished()
330
+ with _tasks_lock:
331
+ vals = list(_tasks.values())
332
+ with _queue_lock:
333
+ queued = len(_pending_queue)
334
+ running = sum(1 for d in vals if d.status == DownloadStatus.RUNNING)
335
+ pending = sum(1 for d in vals if d.status == DownloadStatus.PENDING)
336
+ paused = sum(1 for d in vals if d.status == DownloadStatus.PAUSED)
337
+ failed = sum(1 for d in vals if d.status == DownloadStatus.FAILED)
338
+ completed = sum(1 for d in vals if d.status == DownloadStatus.COMPLETED)
339
+ self._send(200, {
340
+ "running": running, "pending": pending, "paused": paused,
341
+ "failed": failed, "completed": completed, "total": len(vals),
342
+ "queued": queued, "max_concurrent": _max_concurrent,
343
+ })
344
+
345
+ def _get_config(self):
346
+ cfg = load_config()
347
+ self._send(200, {
348
+ "max_segments": cfg.max_segments,
349
+ "speed_limit": cfg.speed_limit,
350
+ "max_concurrent": cfg.max_concurrent,
351
+ "debug": cfg.debug,
352
+ "timeout": cfg.timeout,
353
+ "verify_ssl": cfg.verify_ssl,
354
+ "resume": cfg.resume,
355
+ })
356
+
357
+ # ── 静态文件 ──
358
+
359
+ def _serve_static(self):
360
+ path = urllib.parse.urlparse(self.path).path
361
+ if path in ("", "/"):
362
+ path = "/web_demo.html"
363
+ file_path = os.path.normpath(os.path.join(_STATIC_DIR, path.lstrip("/")))
364
+ if not file_path.startswith(_STATIC_DIR) or not os.path.isfile(file_path):
365
+ self._send(404, {"error": "Not Found"})
366
+ return
367
+ ext = os.path.splitext(file_path)[1].lower()
368
+ mime = {".html": "text/html; charset=utf-8", ".js": "application/javascript",
369
+ ".css": "text/css", ".png": "image/png", ".ico": "image/x-icon"}
370
+ self.send_response(200)
371
+ self.send_header("Content-Type", mime.get(ext, "application/octet-stream"))
372
+ self.send_header("Cache-Control", "no-cache")
373
+ self.end_headers()
374
+ with open(file_path, "rb") as f:
375
+ self.wfile.write(f.read())
376
+
377
+
378
+ # ═══════════════════════════════════════════════
379
+ # 启动
380
+ # ═══════════════════════════════════════════════
381
+
382
+ def run_server(host: str = "127.0.0.1", port: int = 8765):
383
+ server = HTTPServer((host, port), APIHandler)
384
+ server.timeout = 0.5 # 定期检查 KeyboardInterrupt
385
+
386
+ print(f"╔══════════════════════════════════════════════╗")
387
+ print(f"║ HyperDownloader API Server ║")
388
+ print(f"╠══════════════════════════════════════════════╣")
389
+ print(f"║ 🚀 服务已启动 ║")
390
+ print(f"║ 🌐 Web 界面: http://{host}:{port}/ ║")
391
+ print(f"║ 📡 API 地址: http://{host}:{port}/api ║")
392
+ print(f"╚══════════════════════════════════════════════╝")
393
+ print(f" 按 Ctrl+C 停止")
394
+
395
+ try:
396
+ server.serve_forever()
397
+ except KeyboardInterrupt:
398
+ print("\n⏹ 正在停止...")
399
+ finally:
400
+ with _tasks_lock:
401
+ for dl in list(_tasks.values()):
402
+ dl.cancel()
403
+ server.server_close()
404
+ print("✅ 已停止")
405
+
406
+
407
+ def main():
408
+ import argparse
409
+ parser = argparse.ArgumentParser(description="HyperDownloader API Server")
410
+ parser.add_argument("--host", default="127.0.0.1")
411
+ parser.add_argument("--port", type=int, default=8765)
412
+ args = parser.parse_args()
413
+ run_server(args.host, args.port)
414
+
415
+
416
+ if __name__ == "__main__":
417
+ main()
@@ -0,0 +1,176 @@
1
+ """
2
+ 配置管理器
3
+
4
+ 从 JSON 配置文件加载设置,支持多级查找:
5
+ 1. 显式指定的配置文件路径
6
+ 2. 当前工作目录下的 ``config.json``
7
+ 3. ``~/.hyperdownloader/config.json``(用户级)
8
+
9
+ 所有配置项均可被代码中的显式参数覆盖。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger("hyperdownloader.config")
21
+
22
+ # 配置文件名
23
+ CONFIG_FILENAME = "config.json"
24
+ # 用户级配置目录
25
+ USER_CONFIG_DIR = os.path.expanduser("~/.hyperdownloader")
26
+
27
+
28
+ @dataclass
29
+ class AppConfig:
30
+ """应用配置,对应 config.json 的结构"""
31
+
32
+ # ── 下载 ──
33
+ max_segments: int = 4
34
+ """默认分片数"""
35
+ speed_limit: Optional[int] = None
36
+ """全局速度限制(字节/秒)"""
37
+ max_concurrent: int = 3
38
+ """最大并发任务数"""
39
+
40
+ # ── 调试 ──
41
+ debug: bool = False
42
+ """是否启用调试模式(输出分片级详细信息)"""
43
+
44
+ # ── 路径 ──
45
+ default_save_dir: str = ""
46
+ """默认下载保存目录,为空则使用系统下载文件夹"""
47
+ temp_suffix: str = ".hdt"
48
+ """临时文件后缀"""
49
+
50
+ # ── 代理 ──
51
+ proxy_http: Optional[str] = None
52
+ proxy_https: Optional[str] = None
53
+ proxy_socks5: Optional[str] = None
54
+
55
+ # ── 网络 ──
56
+ timeout: float = 30.0
57
+ connect_timeout: float = 10.0
58
+ buffer_size: int = 8192
59
+ max_retries: int = 3
60
+ retry_delay: float = 2.0
61
+ verify_ssl: bool = True
62
+
63
+ # ── 行为 ──
64
+ resume: bool = True
65
+ """是否启用断点续传"""
66
+ show_progress_bar: bool = True
67
+ """是否显示进度条(仅 CLI 有效)"""
68
+
69
+
70
+ def load_config(config_path: Optional[str] = None) -> AppConfig:
71
+ """
72
+ 加载配置,优先级:参数路径 > 工作目录 > 用户目录 > 默认值。
73
+
74
+ Args:
75
+ config_path: 显式指定的配置文件路径
76
+
77
+ Returns:
78
+ AppConfig 实例
79
+ """
80
+ cfg = AppConfig()
81
+
82
+ # 查找配置文件
83
+ paths_to_try: list[str] = []
84
+ if config_path:
85
+ paths_to_try.append(config_path)
86
+
87
+ # 工作目录
88
+ cwd_config = os.path.join(os.getcwd(), CONFIG_FILENAME)
89
+ if cwd_config not in paths_to_try:
90
+ paths_to_try.append(cwd_config)
91
+
92
+ # 用户目录
93
+ user_config = os.path.join(USER_CONFIG_DIR, CONFIG_FILENAME)
94
+ if user_config not in paths_to_try:
95
+ paths_to_try.append(user_config)
96
+
97
+ # 依次尝试加载
98
+ loaded_path = None
99
+ for path in paths_to_try:
100
+ if os.path.isfile(path):
101
+ loaded_path = path
102
+ break
103
+
104
+ if loaded_path:
105
+ try:
106
+ with open(loaded_path, "r", encoding="utf-8") as f:
107
+ data: dict = json.load(f)
108
+ _apply_dict(cfg, data)
109
+ logger.info("已加载配置: %s", loaded_path)
110
+ except Exception as e:
111
+ logger.warning("加载配置失败 %s: %s", loaded_path, e)
112
+ else:
113
+ logger.info("未找到配置文件,使用默认配置")
114
+
115
+ return cfg
116
+
117
+
118
+ def save_config(cfg: AppConfig, config_path: Optional[str] = None) -> str:
119
+ """
120
+ 保存配置到文件。
121
+
122
+ Args:
123
+ cfg: 应用配置
124
+ config_path: 目标路径,默认保存到用户目录
125
+
126
+ Returns:
127
+ 实际保存的文件路径
128
+ """
129
+ path = config_path or os.path.join(USER_CONFIG_DIR, CONFIG_FILENAME)
130
+ os.makedirs(os.path.dirname(path), exist_ok=True)
131
+
132
+ data = _to_dict(cfg)
133
+ with open(path, "w", encoding="utf-8") as f:
134
+ json.dump(data, f, indent=2, ensure_ascii=False)
135
+ logger.info("配置已保存: %s", path)
136
+ return path
137
+
138
+
139
+ def _apply_dict(cfg: AppConfig, data: dict) -> None:
140
+ """将字典中的值写入 AppConfig(仅覆盖非 None 字段)"""
141
+ for key, value in data.items():
142
+ if hasattr(cfg, key) and value is not None:
143
+ # 处理嵌套字段(如 proxy.http)
144
+ if isinstance(value, dict):
145
+ for sub_key, sub_val in value.items():
146
+ attr = f"{key}_{sub_key}"
147
+ if hasattr(cfg, attr) and sub_val is not None:
148
+ setattr(cfg, attr, sub_val)
149
+ else:
150
+ setattr(cfg, key, value)
151
+
152
+
153
+ def _to_dict(cfg: AppConfig) -> dict:
154
+ """将 AppConfig 转为字典"""
155
+ result: dict = {}
156
+ for field_name in (
157
+ "max_segments", "speed_limit", "max_concurrent", "debug",
158
+ "default_save_dir", "temp_suffix",
159
+ "timeout", "connect_timeout", "buffer_size",
160
+ "max_retries", "retry_delay", "verify_ssl",
161
+ "resume", "show_progress_bar",
162
+ ):
163
+ val = getattr(cfg, field_name)
164
+ if val is not None:
165
+ result[field_name] = val
166
+
167
+ # 代理 (嵌套对象)
168
+ proxy: dict[str, Optional[str]] = {}
169
+ for k in ("http", "https", "socks5"):
170
+ val = getattr(cfg, f"proxy_{k}")
171
+ if val is not None:
172
+ proxy[k] = val
173
+ if proxy:
174
+ result["proxy"] = proxy
175
+
176
+ return result