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 +3 -0
- dy_cli/commands/__init__.py +0 -0
- dy_cli/commands/account.py +103 -0
- dy_cli/commands/analytics.py +120 -0
- dy_cli/commands/auth.py +159 -0
- dy_cli/commands/config_cmd.py +67 -0
- dy_cli/commands/download.py +212 -0
- dy_cli/commands/init.py +200 -0
- dy_cli/commands/interact.py +140 -0
- dy_cli/commands/live.py +141 -0
- dy_cli/commands/profile.py +78 -0
- dy_cli/commands/publish.py +123 -0
- dy_cli/commands/search.py +131 -0
- dy_cli/commands/trending.py +82 -0
- dy_cli/engines/__init__.py +0 -0
- dy_cli/engines/api_client.py +665 -0
- dy_cli/engines/playwright_client.py +836 -0
- dy_cli/main.py +144 -0
- dy_cli/utils/__init__.py +0 -0
- dy_cli/utils/config.py +99 -0
- dy_cli/utils/envelope.py +49 -0
- dy_cli/utils/export.py +68 -0
- dy_cli/utils/index_cache.py +83 -0
- dy_cli/utils/output.py +283 -0
- dy_cli/utils/signature.py +183 -0
- dy_cli-0.2.0.dist-info/METADATA +376 -0
- dy_cli-0.2.0.dist-info/RECORD +34 -0
- dy_cli-0.2.0.dist-info/WHEEL +4 -0
- dy_cli-0.2.0.dist-info/entry_points.txt +2 -0
- dy_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- scripts/chrome_launcher.py +71 -0
- scripts/douyin_analytics.py +99 -0
- scripts/douyin_login.py +64 -0
- scripts/douyin_publisher.py +199 -0
dy_cli/__init__.py
ADDED
|
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)
|
dy_cli/commands/auth.py
ADDED
|
@@ -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)
|