返回 douyin-downloader
main.py
根目录 / cli / main.py
1 import argparse
2 import asyncio
3 import json
4 import logging
5 import sys
6 from pathlib import Path
7 from typing import Any
8
9 from auth import CookieManager
10 from cli.progress_display import ProgressDisplay
11 from config import ConfigLoader
12 from control import QueueManager, RateLimiter, RetryHandler
13 from core import DouyinAPIClient, DownloaderFactory, URLParser
14 from storage import Database, FileManager
15 from utils.logger import set_console_log_level, setup_logger
16 from utils.notifier import build_notifier
17 from utils.validators import is_short_url, normalize_short_url
18
19 logger = setup_logger("CLI")
20 display = ProgressDisplay()
21
22
23 def _as_bool(value: Any, default: bool = True) -> bool:
24 if value is None:
25 return default
26 if isinstance(value, bool):
27 return value
28 if isinstance(value, str):
29 return value.strip().lower() in {"1", "true", "yes", "on"}
30 return bool(value)
31
32
33 async def download_url(
34 url: str,
35 config: ConfigLoader,
36 cookie_manager: CookieManager,
37 database: Database = None,
38 progress_reporter: ProgressDisplay = None,
39 ):
40 if progress_reporter:
41 progress_reporter.advance_step("初始化", "创建下载组件")
42 file_manager = FileManager(config.get("path"))
43 rate_limiter = RateLimiter(max_per_second=float(config.get("rate_limit", 2) or 2))
44 retry_handler = RetryHandler(max_retries=config.get("retry_times", 3))
45 queue_manager = QueueManager(max_workers=int(config.get("thread", 5) or 5))
46
47 original_url = url
48
49 async with DouyinAPIClient(
50 cookie_manager.get_cookies(),
51 proxy=config.get("proxy"),
52 ) as api_client:
53 if progress_reporter:
54 progress_reporter.advance_step("解析链接", "检查短链并解析 URL")
55 # 支持多种短链变体:v.douyin.com / v.iesdouyin.com / 无 scheme 的裸链接
56 if is_short_url(url):
57 resolved_url = await api_client.resolve_short_url(normalize_short_url(url))
58 if resolved_url:
59 url = resolved_url
60 else:
61 if progress_reporter:
62 progress_reporter.update_step("解析链接", "短链解析失败")
63 display.print_error(f"Failed to resolve short URL: {url}")
64 return None
65
66 parsed = URLParser.parse(url)
67 if not parsed:
68 if progress_reporter:
69 progress_reporter.update_step("解析链接", "URL 解析失败")
70 display.print_error(f"Failed to parse URL: {url}")
71 return None
72
73 if not progress_reporter:
74 display.print_info(f"URL type: {parsed['type']}")
75 if progress_reporter:
76 progress_reporter.advance_step("创建下载器", f"URL 类型: {parsed['type']}")
77
78 downloader = DownloaderFactory.create(
79 parsed["type"],
80 config,
81 api_client,
82 file_manager,
83 cookie_manager,
84 database,
85 rate_limiter,
86 retry_handler,
87 queue_manager,
88 progress_reporter=progress_reporter,
89 )
90
91 if not downloader:
92 if progress_reporter:
93 progress_reporter.update_step("创建下载器", "未找到匹配下载器")
94 display.print_error(f"No downloader found for type: {parsed['type']}")
95 return None
96
97 if progress_reporter:
98 progress_reporter.advance_step("执行下载", "开始拉取与下载资源")
99 try:
100 result = await downloader.download(parsed)
101 except Exception as exc:
102 # Surface fatal downloader errors (e.g. user_info fetch failed
103 # because cookies are invalid) as a per-URL failure instead of
104 # crashing the whole batch. Keeps multi-URL CLI runs robust while
105 # still telling the user why the URL was skipped.
106 if progress_reporter:
107 progress_reporter.update_step("执行下载", f"失败:{exc}")
108 display.print_error(f"Download failed for {url}: {exc}")
109 return None
110
111 if progress_reporter:
112 progress_reporter.advance_step(
113 "记录历史",
114 "写入数据库历史" if (result and database) else "数据库未启用,跳过",
115 )
116 if result and database:
117 safe_config = {
118 k: v
119 for k, v in config.config.items()
120 if k not in ("cookies", "cookie", "transcript")
121 }
122 await database.add_history(
123 {
124 "url": original_url,
125 "url_type": parsed["type"],
126 "total_count": result.total,
127 "success_count": result.success,
128 "config": json.dumps(safe_config, ensure_ascii=False),
129 }
130 )
131
132 if progress_reporter:
133 if result:
134 progress_reporter.advance_step(
135 "收尾",
136 f"成功 {result.success} / 失败 {result.failed} / 跳过 {result.skipped}",
137 )
138 else:
139 progress_reporter.advance_step("收尾", "无可统计结果")
140
141 return result
142
143
144 async def main_async(args):
145 if not args.serve:
146 display.show_banner()
147
148 if args.config:
149 config_path = args.config
150 else:
151 config_path = "config.yml"
152
153 # 若 config 不存在且使用了 --hot-board / --search / --serve 等独立子命令,
154 # 允许以默认配置运行(只要命令行提供了 --path)。
155 if not Path(config_path).exists():
156 if not (args.hot_board is not None or args.search or args.serve):
157 display.print_error(f"Config file not found: {config_path}")
158 return
159 # For ``--serve`` we still pass the (yet-missing) path so later
160 # ``config.save()`` calls from the REST settings endpoint create
161 # the file in the right place (e.g. Electron's userData dir).
162 # Other subcommands keep the historical behaviour of in-memory
163 # defaults.
164 if args.serve and args.config:
165 config = ConfigLoader(config_path)
166 else:
167 config = ConfigLoader(None)
168 else:
169 config = ConfigLoader(config_path)
170
171 if args.path:
172 config.update(path=args.path)
173
174 # 独立子命令:热榜 / 搜索 / 服务
175 if args.hot_board is not None or args.search:
176 await _run_discovery_subcommand(args, config)
177 return
178 if args.serve:
179 await _run_serve_subcommand(args, config)
180 return
181
182 if args.url:
183 urls = args.url if isinstance(args.url, list) else [args.url]
184 for url in urls:
185 if url not in config.get("link", []):
186 config.update(link=config.get("link", []) + [url])
187
188 if args.thread:
189 config.update(thread=args.thread)
190
191 if not config.validate():
192 display.print_error("Invalid configuration: missing required fields")
193 return
194
195 cookies = config.get_cookies()
196 cookie_manager = CookieManager()
197 cookie_manager.set_cookies(cookies)
198
199 if not cookie_manager.validate_cookies():
200 display.print_warning("Cookies may be invalid or incomplete")
201
202 database = None
203 if config.get("database"):
204 db_path = config.get("database_path", "dy_downloader.db") or "dy_downloader.db"
205 database = Database(db_path=str(db_path))
206 await database.initialize()
207 display.print_success("Database initialized")
208
209 urls = config.get_links()
210 display.print_info(f"Found {len(urls)} URL(s) to process")
211
212 all_results = []
213 progress_config = config.get("progress", {}) or {}
214 quiet_by_config = _as_bool(progress_config.get("quiet_logs", True), default=True)
215 quiet_progress_logs = quiet_by_config and not (args.verbose or args.show_warnings)
216 if quiet_progress_logs:
217 # Progress 运行期间若有大量错误日志会触发 rich 反复重绘,导致屏幕出现重复块。
218 # 默认静默控制台日志,下载完成后再恢复。
219 set_console_log_level(logging.CRITICAL)
220
221 display.start_download_session(len(urls))
222 try:
223 for i, url in enumerate(urls, 1):
224 display.start_url(i, len(urls), url)
225
226 result = await download_url(
227 url,
228 config,
229 cookie_manager,
230 database,
231 progress_reporter=display,
232 )
233 if result:
234 all_results.append(result)
235 display.complete_url(result)
236 else:
237 display.fail_url("下载失败或链接无效")
238 finally:
239 display.stop_download_session()
240 if database is not None:
241 await database.close()
242 if quiet_progress_logs:
243 set_console_log_level(logging.ERROR)
244
245 if all_results:
246 from core.downloader_base import DownloadResult
247
248 total_result = DownloadResult()
249 for r in all_results:
250 total_result.total += r.total
251 total_result.success += r.success
252 total_result.failed += r.failed
253 total_result.skipped += r.skipped
254
255 display.print_success("\n=== Overall Summary ===")
256 display.show_result(total_result)
257
258 await _dispatch_notifications(config, total_result, len(urls))
259 else:
260 # 所有链接都失败时,也发通知(若启用)
261 await _dispatch_notifications(config, None, len(urls))
262
263
264 async def _run_discovery_subcommand(args, config: ConfigLoader) -> None:
265 """处理 --hot-board 与 --search 子命令。"""
266 from core.discovery import dump_hot_board, search_and_dump
267
268 cookies = config.get_cookies()
269 cookie_manager = CookieManager()
270 cookie_manager.set_cookies(cookies)
271
272 base_path = Path(config.get("path") or "./Downloaded/")
273
274 async with DouyinAPIClient(cookie_manager.get_cookies()) as api_client:
275 if args.hot_board is not None:
276 display.print_info("拉取抖音热搜榜...")
277 result = await dump_hot_board(api_client, base_path, limit=int(args.hot_board or 0))
278 display.print_success(f"热榜已保存:{result['count']} 条 -> {result['path']}")
279 if args.search:
280 display.print_info(f"搜索关键词:{args.search}")
281 result = await search_and_dump(
282 api_client,
283 args.search,
284 base_path,
285 max_items=int(args.search_max or 50),
286 )
287 display.print_success(f"搜索结果已保存:{result['count']} 条 -> {result['path']}")
288
289
290 async def _run_serve_subcommand(args, config: ConfigLoader) -> None:
291 """启动 REST API 服务模式(fastapi + uvicorn 为可选依赖)。"""
292 try:
293 from server.app import run_server
294 except ImportError as exc:
295 display.print_error(
296 f"REST 服务模式需要安装可选依赖 fastapi + uvicorn:"
297 f"\n pip install fastapi uvicorn\n原始错误:{exc}"
298 )
299 return
300
301 display.print_info(f"启动 REST 服务:http://{args.serve_host}:{args.serve_port}")
302 await run_server(config, host=args.serve_host, port=args.serve_port)
303
304
305 async def _dispatch_notifications(config: ConfigLoader, total_result: Any, url_count: int) -> None:
306 notifier = build_notifier(config)
307 if not notifier.enabled:
308 return
309
310 if total_result is None:
311 title = "抖音下载器:全部失败"
312 body = f"共处理 {url_count} 个链接,无成功结果"
313 level = "failure"
314 else:
315 fail_or_partial = total_result.failed > 0 or total_result.success == 0
316 level = "failure" if fail_or_partial else "success"
317 title = "抖音下载完成" if level == "success" else "抖音下载部分失败"
318 body = (
319 f"链接 {url_count} / 总作品 {total_result.total} / "
320 f"成功 {total_result.success} / 失败 {total_result.failed} / "
321 f"跳过 {total_result.skipped}"
322 )
323
324 try:
325 summary = await notifier.send(title=title, body=body, level=level)
326 if summary:
327 succ = sum(1 for ok in summary.values() if ok)
328 logger.info(
329 "Notification dispatched to %d provider(s), %d ok",
330 len(summary),
331 succ,
332 )
333 except Exception as exc: # 通知失败不应影响主流程
334 logger.warning("Notification dispatch error: %s", exc)
335
336
337 def main():
338 parser = argparse.ArgumentParser(description="Douyin Downloader - 抖音批量下载工具")
339 parser.add_argument("-u", "--url", action="append", help="Download URL(s)")
340 parser.add_argument("-c", "--config", help="Config file path (default: config.yml)")
341 parser.add_argument("-p", "--path", help="Save path")
342 parser.add_argument("-t", "--thread", type=int, help="Thread count")
343 parser.add_argument("--show-warnings", action="store_true", help="Show warning logs in console")
344 parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose console logs")
345 parser.add_argument(
346 "--hot-board",
347 type=int,
348 nargs="?",
349 const=0,
350 default=None,
351 metavar="N",
352 help="拉取抖音热搜榜并导出 JSONL,可选上限 N(默认全部)",
353 )
354 parser.add_argument(
355 "--search",
356 type=str,
357 default=None,
358 metavar="KEYWORD",
359 help="按关键词搜索作品并导出 JSONL",
360 )
361 parser.add_argument(
362 "--search-max",
363 type=int,
364 default=50,
365 help="--search 场景下最多拉取条数(默认 50)",
366 )
367 parser.add_argument(
368 "--serve",
369 action="store_true",
370 help="以 REST API 服务模式运行(需要安装 fastapi + uvicorn)",
371 )
372 parser.add_argument("--serve-host", type=str, default="127.0.0.1", help="REST 服务监听地址")
373 parser.add_argument("--serve-port", type=int, default=8000, help="REST 服务监听端口")
374 try:
375 from __init__ import __version__
376 except ImportError:
377 __version__ = "2.0.0"
378 parser.add_argument("--version", action="version", version=__version__)
379
380 args = parser.parse_args()
381
382 if args.verbose:
383 set_console_log_level(logging.INFO)
384 elif args.show_warnings:
385 set_console_log_level(logging.WARNING)
386 else:
387 set_console_log_level(logging.ERROR)
388
389 try:
390 asyncio.run(main_async(args))
391 except KeyboardInterrupt:
392 display.print_warning("\nDownload interrupted by user")
393 sys.exit(0)
394 except Exception as e:
395 display.print_error(f"Fatal error: {e}")
396 logger.exception("Fatal error occurred")
397 sys.exit(1)
398
399
400 if __name__ == "__main__":
401 main()
402
402 lines PYTHON