dy-cli 0.2.0__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.
dy_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dy-cli — 抖音命令行工具。"""
2
+
3
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,103 @@
1
+ """
2
+ dy account — 多账号管理命令。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ import click
9
+ from rich.table import Table
10
+ from rich import box
11
+
12
+ from dy_cli.engines.playwright_client import PlaywrightClient
13
+ from dy_cli.utils import config
14
+ from dy_cli.utils.output import success, error, info, console
15
+
16
+
17
+ @click.group("account", help="多账号管理")
18
+ def account_group():
19
+ pass
20
+
21
+
22
+ @account_group.command("list", help="列出所有账号")
23
+ def list_accounts():
24
+ """列出已配置的账号。"""
25
+ cookies_dir = config.COOKIES_DIR
26
+ default_account = config.load_config()["default"]["account"]
27
+
28
+ if not os.path.isdir(cookies_dir):
29
+ info("暂无配置账号")
30
+ info("使用 [bold]dy account add <name>[/] 添加账号")
31
+ return
32
+
33
+ files = [f for f in os.listdir(cookies_dir) if f.endswith(".json")]
34
+ if not files:
35
+ info("暂无配置账号")
36
+ return
37
+
38
+ table = Table(title="📱 账号列表", box=box.ROUNDED)
39
+ table.add_column("名称", style="bold")
40
+ table.add_column("Cookie 文件")
41
+ table.add_column("状态")
42
+ table.add_column("默认", justify="center")
43
+
44
+ for f in sorted(files):
45
+ name = f.replace(".json", "")
46
+ cookie_path = os.path.join(cookies_dir, f)
47
+ size = os.path.getsize(cookie_path)
48
+ status_text = "✅ 有效" if size > 100 else "⚠️ 空"
49
+ is_default = "⭐" if name == default_account else ""
50
+ table.add_row(name, cookie_path, status_text, is_default)
51
+
52
+ console.print(table)
53
+
54
+
55
+ @account_group.command("add", help="添加新账号并登录")
56
+ @click.argument("name")
57
+ def add_account(name):
58
+ """添加新账号并打开浏览器登录。"""
59
+ cookie_file = config.get_cookie_file(name)
60
+ if os.path.isfile(cookie_file):
61
+ if not click.confirm(f"账号 '{name}' 已存在,是否重新登录?", default=False):
62
+ return
63
+
64
+ info(f"正在为账号 '{name}' 打开登录页面...")
65
+ client = PlaywrightClient(account=name, headless=False)
66
+ try:
67
+ ok = client.login()
68
+ if ok:
69
+ success(f"账号 '{name}' 已添加并登录")
70
+ else:
71
+ error("登录失败")
72
+ except Exception as e:
73
+ error(f"登录失败: {e}")
74
+ raise SystemExit(1)
75
+
76
+
77
+ @account_group.command("remove", help="删除账号")
78
+ @click.argument("name")
79
+ @click.confirmation_option(prompt="确认删除此账号?")
80
+ def remove_account(name):
81
+ """删除账号 (Cookie 文件)。"""
82
+ cookie_file = config.get_cookie_file(name)
83
+ if os.path.isfile(cookie_file):
84
+ os.remove(cookie_file)
85
+ success(f"账号 '{name}' 已删除")
86
+ else:
87
+ error(f"账号 '{name}' 不存在")
88
+ raise SystemExit(1)
89
+
90
+
91
+ @account_group.command("default", help="设置默认账号")
92
+ @click.argument("name")
93
+ def set_default(name):
94
+ """设置默认账号。"""
95
+ cookie_file = config.get_cookie_file(name)
96
+ if not os.path.isfile(cookie_file):
97
+ warning_text = f"账号 '{name}' 尚未登录"
98
+ info(warning_text)
99
+ if not click.confirm("仍要设为默认?", default=False):
100
+ return
101
+
102
+ config.set_value("default.account", name)
103
+ success(f"默认账号已设为: {name}")
@@ -0,0 +1,120 @@
1
+ """
2
+ dy analytics / notifications — 数据分析命令 (Playwright)。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+
9
+ import click
10
+
11
+ from dy_cli.engines.playwright_client import PlaywrightClient, PlaywrightError
12
+ from dy_cli.utils import config
13
+ from dy_cli.utils.output import (
14
+ success, error, info, warning, console,
15
+ print_analytics, print_json,
16
+ )
17
+
18
+
19
+ @click.command("analytics", help="📊 数据看板 (创作者数据分析)")
20
+ @click.option("--csv", "csv_file", default=None, help="导出 CSV 文件路径")
21
+ @click.option("--page-size", type=int, default=10, help="每页条数 (默认 10)")
22
+ @click.option("--account", default=None, help="账号名")
23
+ @click.option("--json-output", "as_json", is_flag=True, help="输出 JSON")
24
+ def analytics(csv_file, page_size, account, as_json):
25
+ """获取创作者数据看板 (Playwright 引擎)。"""
26
+ cfg = config.load_config()
27
+ client = PlaywrightClient(
28
+ account=account,
29
+ headless=True,
30
+ )
31
+
32
+ if not client.cookie_exists():
33
+ error("未登录,请先运行: dy login")
34
+ raise SystemExit(1)
35
+
36
+ info("正在获取数据看板 (Playwright)...")
37
+
38
+ try:
39
+ result = client.get_analytics(page_size=page_size)
40
+ except PlaywrightError as e:
41
+ error(f"获取数据失败: {e}")
42
+ info("请确保已登录: dy status")
43
+ raise SystemExit(1)
44
+
45
+ if as_json:
46
+ print_json(result)
47
+ else:
48
+ print_analytics(result)
49
+
50
+ # Export CSV
51
+ if csv_file:
52
+ rows = result.get("rows", [])
53
+ if rows:
54
+ import csv
55
+ keys = rows[0].keys()
56
+ with open(csv_file, "w", newline="", encoding="utf-8-sig") as f:
57
+ writer = csv.DictWriter(f, fieldnames=keys)
58
+ writer.writeheader()
59
+ writer.writerows(rows)
60
+ success(f"CSV 已导出: {csv_file}")
61
+ else:
62
+ warning("无数据可导出")
63
+
64
+
65
+ @click.command("notifications", help="🔔 通知消息")
66
+ @click.option("--account", default=None, help="账号名")
67
+ @click.option("--json-output", "as_json", is_flag=True, help="输出 JSON")
68
+ def notifications(account, as_json):
69
+ """获取通知消息 (Playwright 引擎)。"""
70
+ cfg = config.load_config()
71
+ client = PlaywrightClient(
72
+ account=account,
73
+ headless=True,
74
+ )
75
+
76
+ if not client.cookie_exists():
77
+ error("未登录,请先运行: dy login")
78
+ raise SystemExit(1)
79
+
80
+ info("正在获取通知消息...")
81
+
82
+ try:
83
+ result = client.get_notifications()
84
+ except PlaywrightError as e:
85
+ error(f"获取通知失败: {e}")
86
+ raise SystemExit(1)
87
+
88
+ if as_json:
89
+ print_json(result)
90
+ else:
91
+ _print_notifications(result)
92
+
93
+
94
+ def _print_notifications(data: dict):
95
+ """格式化输出通知。"""
96
+ from rich.table import Table
97
+ from rich import box
98
+
99
+ mentions = data.get("mentions", [])
100
+ if not mentions:
101
+ info("暂无新通知")
102
+ return
103
+
104
+ table = Table(title="🔔 通知消息", box=box.ROUNDED, show_lines=True)
105
+ table.add_column("#", style="dim", width=3)
106
+ table.add_column("类型", width=8)
107
+ table.add_column("用户", max_width=15)
108
+ table.add_column("内容", max_width=40, overflow="fold")
109
+ table.add_column("时间", width=16)
110
+
111
+ for i, mention in enumerate(mentions, 1):
112
+ table.add_row(
113
+ str(i),
114
+ mention.get("type", "-"),
115
+ mention.get("user", "-"),
116
+ mention.get("content", "-"),
117
+ mention.get("time", "-"),
118
+ )
119
+
120
+ console.print(table)
@@ -0,0 +1,159 @@
1
+ """
2
+ dy login / logout / status — 认证命令。
3
+
4
+ 支持两种登录方式:
5
+ 1. 浏览器 Cookie 自动提取 (默认, 零摩擦)
6
+ 2. Playwright 扫码 (--qrcode)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+
13
+ import click
14
+
15
+ from dy_cli.engines.playwright_client import PlaywrightClient, PlaywrightError
16
+ from dy_cli.utils import config
17
+ from dy_cli.utils.output import success, error, info, warning, status, console
18
+
19
+
20
+ def _extract_browser_cookies(account: str | None = None) -> bool:
21
+ """从浏览器自动提取抖音 Cookie(需要用户在浏览器中已登录抖音)。"""
22
+ try:
23
+ import browser_cookie3 as bc3
24
+ except ImportError:
25
+ return False
26
+
27
+ # 从多个域名收集 cookie
28
+ all_cookies: dict[str, dict] = {}
29
+ browsers = ["chrome", "firefox", "edge", "brave", "opera", "chromium", "safari"]
30
+ domains = [".douyin.com", "www.douyin.com", "creator.douyin.com"]
31
+
32
+ found_browser = None
33
+ for browser_name in browsers:
34
+ loader = getattr(bc3, browser_name, None)
35
+ if not loader:
36
+ continue
37
+ for domain in domains:
38
+ try:
39
+ jar = loader(domain_name=domain)
40
+ for c in jar:
41
+ if "douyin" in (c.domain or ""):
42
+ all_cookies[c.name] = {
43
+ "name": c.name,
44
+ "value": c.value,
45
+ "domain": c.domain,
46
+ "path": c.path or "/",
47
+ }
48
+ found_browser = browser_name
49
+ except Exception:
50
+ continue
51
+ if all_cookies:
52
+ break
53
+
54
+ if not all_cookies:
55
+ return False
56
+
57
+ # 检查是否有关键 session cookie
58
+ key_names = {"sessionid", "passport_csrf_token", "odin_tt", "sid_guard"}
59
+ has_session = bool(key_names & set(all_cookies.keys()))
60
+
61
+ if not has_session:
62
+ info(f"从 {found_browser} 提取了 {len(all_cookies)} 个 cookie,但缺少登录态")
63
+ info("请先在浏览器中登录 douyin.com,然后重试")
64
+ return False
65
+
66
+ # 保存为 Playwright storage_state 格式
67
+ cookie_file = config.get_cookie_file(account)
68
+ os.makedirs(os.path.dirname(cookie_file), exist_ok=True)
69
+ storage = {
70
+ "cookies": list(all_cookies.values()),
71
+ "origins": [],
72
+ }
73
+ with open(cookie_file, "w", encoding="utf-8") as f:
74
+ json.dump(storage, f, ensure_ascii=False, indent=2)
75
+ info(f"从 {found_browser} 提取了 {len(all_cookies)} 个 cookie (含登录态)")
76
+ return True
77
+
78
+
79
+ @click.command("login", help="登录抖音")
80
+ @click.option("--account", default=None, help="账号名")
81
+ @click.option("--browser", is_flag=True, help="从浏览器提取 Cookie (需在浏览器中已登录抖音)")
82
+ def login(account, browser):
83
+ """登录抖音。默认扫码登录,--browser 从浏览器提取 Cookie。"""
84
+ cfg = config.load_config()
85
+
86
+ # 已登录检查
87
+ client = PlaywrightClient(account=account, headless=True)
88
+ if client.cookie_exists():
89
+ try:
90
+ if client.check_login():
91
+ success("已登录抖音")
92
+ if not click.confirm("是否重新登录?", default=False):
93
+ return
94
+ except Exception:
95
+ pass
96
+
97
+ # 方式 1: 从浏览器提取 Cookie
98
+ if browser:
99
+ info("正在从浏览器提取 Cookie...")
100
+ if _extract_browser_cookies(account):
101
+ success("登录成功! 🎉 (从浏览器提取)")
102
+ return
103
+ else:
104
+ warning("浏览器 Cookie 提取失败,切换到扫码模式")
105
+
106
+ # 方式 2: Playwright 扫码 (默认)
107
+ info("正在打开浏览器,请使用抖音 App 扫码...")
108
+ pw_client = PlaywrightClient(
109
+ account=account,
110
+ headless=False,
111
+ slow_mo=cfg["playwright"].get("slow_mo", 0),
112
+ )
113
+ try:
114
+ ok = pw_client.login()
115
+ if ok:
116
+ success("登录成功! 🎉")
117
+ else:
118
+ error("登录超时或失败")
119
+ raise SystemExit(1)
120
+ except PlaywrightError as e:
121
+ error(f"登录失败: {e}")
122
+ raise SystemExit(1)
123
+
124
+
125
+ @click.command("logout", help="退出登录")
126
+ @click.option("--account", default=None, help="账号名")
127
+ def logout(account):
128
+ """退出登录(删除 Cookie)。"""
129
+ client = PlaywrightClient(account=account)
130
+ if client.logout():
131
+ success("已退出登录,Cookie 已删除")
132
+ else:
133
+ info("未找到登录凭据")
134
+
135
+
136
+ @click.command("status", help="查看登录状态")
137
+ @click.option("--account", default=None, help="账号名")
138
+ def auth_status(account):
139
+ """检查登录状态。"""
140
+ console.print()
141
+ client = PlaywrightClient(account=account)
142
+
143
+ if not client.cookie_exists():
144
+ status("登录状态", "未登录 (无 Cookie 文件)", "red")
145
+ info("使用 [bold]dy login[/] 登录")
146
+ else:
147
+ info("正在验证 Cookie...")
148
+ try:
149
+ logged_in = client.check_login()
150
+ if logged_in:
151
+ status("登录状态", "已登录", "green")
152
+ status("Cookie", client.cookie_file, "dim")
153
+ else:
154
+ status("登录状态", "Cookie 已失效", "yellow")
155
+ info("使用 [bold]dy login[/] 重新登录")
156
+ except Exception as e:
157
+ status("登录状态", f"检查失败: {e}", "red")
158
+
159
+ console.print()
@@ -0,0 +1,67 @@
1
+ """
2
+ dy config — 配置管理命令。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import click
7
+
8
+ from dy_cli.utils import config
9
+ from dy_cli.utils.output import success, error, info, console, print_json
10
+
11
+
12
+ @click.group("config", help="配置管理")
13
+ def config_group():
14
+ pass
15
+
16
+
17
+ @config_group.command("show", help="显示当前配置")
18
+ def show():
19
+ """显示所有配置。"""
20
+ cfg = config.load_config()
21
+ print_json(cfg)
22
+ info(f"配置文件: {config.CONFIG_FILE}")
23
+
24
+
25
+ @config_group.command("set", help="设置配置项")
26
+ @click.argument("key")
27
+ @click.argument("value")
28
+ def set_config(key, value):
29
+ """
30
+ 设置配置项。
31
+
32
+ KEY 格式: api.proxy, playwright.headless, default.engine
33
+
34
+ 示例:
35
+ dy config set api.proxy http://127.0.0.1:7897
36
+ dy config set api.timeout 60
37
+ dy config set playwright.headless true
38
+ dy config set default.engine api
39
+ dy config set default.download_dir ~/Videos/douyin
40
+ """
41
+ # Type inference
42
+ if value.lower() in ("true", "false"):
43
+ value = value.lower() == "true"
44
+ elif value.isdigit():
45
+ value = int(value)
46
+
47
+ config.set_value(key, value)
48
+ success(f"已设置 {key} = {value}")
49
+
50
+
51
+ @config_group.command("get", help="获取配置项")
52
+ @click.argument("key")
53
+ def get_config(key):
54
+ """获取单个配置项。"""
55
+ value = config.get(key)
56
+ if value is None:
57
+ error(f"配置项不存在: {key}")
58
+ raise SystemExit(1)
59
+ console.print(f"{key} = {value}")
60
+
61
+
62
+ @config_group.command("reset", help="重置为默认配置")
63
+ @click.confirmation_option(prompt="确认重置所有配置?")
64
+ def reset():
65
+ """重置为默认配置。"""
66
+ config.save_config(config.DEFAULT_CONFIG)
67
+ success("配置已重置为默认值")
@@ -0,0 +1,212 @@
1
+ """
2
+ dy download — 无水印下载命令(抖音特色功能)。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+
9
+ import click
10
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, DownloadColumn, TransferSpeedColumn
11
+
12
+ from dy_cli.engines.api_client import DouyinAPIClient, DouyinAPIError
13
+ from dy_cli.utils import config
14
+ from dy_cli.utils.index_cache import resolve_id
15
+ from dy_cli.utils.output import success, error, info, warning, console
16
+
17
+
18
+ @click.command("download", help="下载抖音视频/图片 (无水印, 支持短索引/批量)")
19
+ @click.argument("url_or_id")
20
+ @click.option("--output-dir", "-o", default=None, help="保存目录 (默认 ~/Downloads/douyin)")
21
+ @click.option("--music", is_flag=True, help="同时下载背景音乐")
22
+ @click.option("--limit", type=int, default=0, help="批量下载: 用户作品数量 (需配合 --user)")
23
+ @click.option("--user", is_flag=True, help="批量下载该用户的全部作品 (URL_OR_ID 为 sec_user_id)")
24
+ @click.option("--account", default=None, help="使用指定账号")
25
+ @click.option("--json-output", "as_json", is_flag=True, help="仅输出下载链接 (JSON)")
26
+ def download(url_or_id, output_dir, music, limit, user, account, as_json):
27
+ """
28
+ 下载抖音视频/图片(无水印)。支持短索引和批量下载。
29
+
30
+ 单个下载:
31
+ dy dl 1 (搜索后用短索引)
32
+ dy dl https://v.douyin.com/xxx (分享链接)
33
+ dy dl 1234567890 (视频 ID)
34
+
35
+ 批量下载用户作品:
36
+ dy dl SEC_USER_ID --user --limit 20
37
+ """
38
+ cfg = config.load_config()
39
+ output_dir = output_dir or cfg["default"].get("download_dir", os.path.expanduser("~/Downloads/douyin"))
40
+ os.makedirs(output_dir, exist_ok=True)
41
+
42
+ client = DouyinAPIClient.from_config(account)
43
+
44
+ try:
45
+ # 批量下载用户作品
46
+ if user:
47
+ _batch_download_user(client, url_or_id, output_dir, music, limit or 20, as_json)
48
+ return
49
+
50
+ # Resolve aweme_id (支持短索引)
51
+ try:
52
+ url_or_id = resolve_id(url_or_id)
53
+ except ValueError as e:
54
+ error(str(e))
55
+ raise SystemExit(1)
56
+ if url_or_id.isdigit():
57
+ aweme_id = url_or_id
58
+ else:
59
+ info("正在解析分享链接...")
60
+ aweme_id = client.resolve_share_url(url_or_id)
61
+
62
+ info(f"视频 ID: {aweme_id}")
63
+
64
+ # Get download info
65
+ info("正在获取下载链接...")
66
+ dl_info = client.get_download_url(aweme_id)
67
+
68
+ if as_json:
69
+ from dy_cli.utils.output import print_json
70
+ print_json(dl_info)
71
+ return
72
+
73
+ desc = dl_info.get("desc", "untitled")
74
+ author = dl_info.get("author", "unknown")
75
+
76
+ # Sanitize filename
77
+ safe_name = re.sub(r'[\\/:*?"<>|\n\r]', '_', desc)[:50].strip('_') or aweme_id
78
+ prefix = f"{author}_{safe_name}"
79
+
80
+ downloaded_files = []
81
+
82
+ # Download video
83
+ video_url = dl_info.get("video_url")
84
+ if video_url:
85
+ video_path = os.path.join(output_dir, f"{prefix}.mp4")
86
+ info(f"正在下载视频...")
87
+ _download_with_progress(client, video_url, video_path)
88
+ downloaded_files.append(video_path)
89
+
90
+ # Download images (for image posts)
91
+ image_urls = dl_info.get("images")
92
+ if image_urls:
93
+ for idx, img_url in enumerate(image_urls, 1):
94
+ img_path = os.path.join(output_dir, f"{prefix}_{idx}.jpg")
95
+ info(f"正在下载图片 {idx}/{len(image_urls)}...")
96
+ _download_with_progress(client, img_url, img_path)
97
+ downloaded_files.append(img_path)
98
+
99
+ # Download music
100
+ if music:
101
+ music_url = dl_info.get("music_url")
102
+ if music_url:
103
+ music_path = os.path.join(output_dir, f"{prefix}_music.mp3")
104
+ info(f"正在下载音乐...")
105
+ _download_with_progress(client, music_url, music_path)
106
+ downloaded_files.append(music_path)
107
+ else:
108
+ warning("未找到背景音乐")
109
+
110
+ # Summary
111
+ if downloaded_files:
112
+ console.print()
113
+ success(f"下载完成! ({len(downloaded_files)} 个文件)")
114
+ for f in downloaded_files:
115
+ size = os.path.getsize(f)
116
+ size_str = f"{size / 1024 / 1024:.1f}MB" if size > 1024 * 1024 else f"{size / 1024:.0f}KB"
117
+ console.print(f" 📁 {f} ({size_str})")
118
+ else:
119
+ warning("未找到可下载的内容")
120
+
121
+ except DouyinAPIError as e:
122
+ error(f"下载失败: {e}")
123
+ raise SystemExit(1)
124
+ finally:
125
+ client.close()
126
+
127
+
128
+ def _batch_download_user(
129
+ client: DouyinAPIClient,
130
+ sec_user_id: str,
131
+ output_dir: str,
132
+ music: bool,
133
+ limit: int,
134
+ as_json: bool,
135
+ ):
136
+ """批量下载用户作品。"""
137
+ info(f"正在获取用户作品列表 (limit={limit})...")
138
+ try:
139
+ profile = client.get_user_profile(sec_user_id)
140
+ nickname = profile.get("nickname", sec_user_id)
141
+ info(f"用户: {nickname}")
142
+ except Exception:
143
+ nickname = sec_user_id
144
+
145
+ user_dir = os.path.join(output_dir, re.sub(r'[\\/:*?"<>|\n\r]', '_', nickname))
146
+ os.makedirs(user_dir, exist_ok=True)
147
+
148
+ posts = client.get_user_posts(sec_user_id, count=min(limit, 20))
149
+ aweme_list = posts.get("aweme_list", [])
150
+
151
+ if not aweme_list:
152
+ warning("未找到作品")
153
+ return
154
+
155
+ info(f"找到 {len(aweme_list)} 个作品,开始下载...")
156
+ downloaded = 0
157
+ import time
158
+
159
+ for i, aweme in enumerate(aweme_list[:limit], 1):
160
+ aweme_id = aweme.get("aweme_id", "")
161
+ desc = aweme.get("desc", "untitled")
162
+ safe = re.sub(r'[\\/:*?"<>|\n\r]', '_', desc)[:40].strip('_') or aweme_id
163
+
164
+ try:
165
+ dl_info = client.get_download_url(aweme_id)
166
+ video_url = dl_info.get("video_url")
167
+ if video_url:
168
+ path = os.path.join(user_dir, f"{i:03d}_{safe}.mp4")
169
+ if os.path.exists(path):
170
+ info(f"[{i}/{len(aweme_list)}] 已存在,跳过: {safe[:30]}")
171
+ continue
172
+ info(f"[{i}/{len(aweme_list)}] {safe[:30]}...")
173
+ _download_with_progress(client, video_url, path)
174
+ downloaded += 1
175
+
176
+ # Images
177
+ images = dl_info.get("images")
178
+ if images:
179
+ for idx, img_url in enumerate(images, 1):
180
+ path = os.path.join(user_dir, f"{i:03d}_{safe}_{idx}.jpg")
181
+ client.download_file(img_url, path)
182
+ downloaded += 1
183
+
184
+ time.sleep(1) # Rate limit
185
+ except Exception as e:
186
+ warning(f"[{i}] 下载失败: {e}")
187
+ continue
188
+
189
+ console.print()
190
+ success(f"批量下载完成! {downloaded}/{len(aweme_list)} 个作品")
191
+ console.print(f" 📁 {user_dir}")
192
+
193
+
194
+ def _download_with_progress(client: DouyinAPIClient, url: str, output_path: str):
195
+ """带进度条的下载。"""
196
+ with Progress(
197
+ SpinnerColumn(),
198
+ TextColumn("[bold blue]{task.description}"),
199
+ BarColumn(),
200
+ DownloadColumn(),
201
+ TransferSpeedColumn(),
202
+ console=console,
203
+ ) as progress:
204
+ task = progress.add_task(os.path.basename(output_path), total=None)
205
+
206
+ def on_progress(downloaded: int, total: int):
207
+ if total > 0:
208
+ progress.update(task, total=total, completed=downloaded)
209
+ else:
210
+ progress.update(task, completed=downloaded)
211
+
212
+ client.download_file(url, output_path, progress_callback=on_progress)