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/main.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ dy — 抖音命令行工具主入口。
3
+
4
+ Usage:
5
+ dy search "关键词" 搜索视频
6
+ dy trending 抖音热榜
7
+ dy download URL 无水印下载
8
+ dy publish -t 标题 -c 描述 -v 视频 发布视频
9
+ dy detail AWEME_ID 视频详情
10
+ dy comments AWEME_ID 查看评论
11
+ dy like AWEME_ID 点赞
12
+ dy comment AWEME_ID -c "内容" 评论
13
+ dy favorite AWEME_ID 收藏
14
+ dy follow SEC_USER_ID 关注
15
+ dy live info ROOM_ID 直播信息
16
+ dy live record ROOM_ID 录制直播
17
+ dy analytics 数据看板
18
+ dy notifications 通知消息
19
+ dy me 我的信息
20
+ dy profile SEC_USER_ID 用户主页
21
+ dy login 登录
22
+ dy logout 退出登录
23
+ dy status 登录状态
24
+ dy account list|add|remove|default 多账号管理
25
+ dy config show|set|get|reset 配置管理
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import click
30
+
31
+ from dy_cli import __version__
32
+
33
+
34
+ BANNER = r"""
35
+ ╔═══════════════════════════════╗
36
+ ║ 🎬 dy-cli v{version} ║
37
+ ║ 抖音命令行工具 ║
38
+ ╚═══════════════════════════════╝
39
+ """.format(version=__version__)
40
+
41
+
42
+ class AliasGroup(click.Group):
43
+ """支持命令别名的 Click Group。"""
44
+
45
+ ALIASES = {
46
+ "pub": "publish",
47
+ "s": "search",
48
+ "dl": "download",
49
+ "t": "trending",
50
+ "r": "detail",
51
+ "read": "detail",
52
+ "fav": "favorite",
53
+ "noti": "notifications",
54
+ "stat": "status",
55
+ "acc": "account",
56
+ "cfg": "config",
57
+ }
58
+
59
+ def get_command(self, ctx, cmd_name):
60
+ resolved = self.ALIASES.get(cmd_name, cmd_name)
61
+ return super().get_command(ctx, resolved)
62
+
63
+ def format_help(self, ctx, formatter):
64
+ formatter.write(BANNER)
65
+ super().format_help(ctx, formatter)
66
+
67
+
68
+ @click.group(cls=AliasGroup, invoke_without_command=True)
69
+ @click.version_option(version=__version__, prog_name="dy-cli")
70
+ @click.pass_context
71
+ def cli(ctx):
72
+ """🎬 抖音命令行工具 — 搜索、下载、发布、互动、热榜、直播、数据分析"""
73
+ if ctx.invoked_subcommand is None:
74
+ click.echo(ctx.get_help())
75
+
76
+
77
+ # ------------------------------------------------------------------
78
+ # 注册所有命令
79
+ # ------------------------------------------------------------------
80
+
81
+ # 初始化
82
+ from dy_cli.commands.init import init
83
+ cli.add_command(init)
84
+
85
+ # 认证
86
+ from dy_cli.commands.auth import login, logout, auth_status
87
+ cli.add_command(login)
88
+ cli.add_command(logout)
89
+ cli.add_command(auth_status, "status")
90
+
91
+ # 搜索 & 详情
92
+ from dy_cli.commands.search import search, detail
93
+ cli.add_command(search)
94
+ cli.add_command(detail)
95
+
96
+ # 下载
97
+ from dy_cli.commands.download import download
98
+ cli.add_command(download)
99
+
100
+ # 发布
101
+ from dy_cli.commands.publish import publish
102
+ cli.add_command(publish)
103
+
104
+ # 互动
105
+ from dy_cli.commands.interact import like, favorite, comment, comments, follow
106
+ cli.add_command(like)
107
+ cli.add_command(favorite)
108
+ cli.add_command(comment)
109
+ cli.add_command(comments)
110
+ cli.add_command(follow)
111
+
112
+ # 热榜
113
+ from dy_cli.commands.trending import trending
114
+ cli.add_command(trending)
115
+
116
+ # 直播
117
+ from dy_cli.commands.live import live_group
118
+ cli.add_command(live_group, "live")
119
+
120
+ # 数据分析
121
+ from dy_cli.commands.analytics import analytics, notifications
122
+ cli.add_command(analytics)
123
+ cli.add_command(notifications)
124
+
125
+ # 用户
126
+ from dy_cli.commands.profile import me, profile
127
+ cli.add_command(me)
128
+ cli.add_command(profile)
129
+
130
+ # 账号管理
131
+ from dy_cli.commands.account import account_group
132
+ cli.add_command(account_group, "account")
133
+
134
+ # 配置管理
135
+ from dy_cli.commands.config_cmd import config_group
136
+ cli.add_command(config_group, "config")
137
+
138
+
139
+ def main():
140
+ cli()
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
File without changes
dy_cli/utils/config.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ 全局配置管理 — ~/.dy/config.json
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from typing import Any
9
+
10
+ CONFIG_DIR = os.path.expanduser("~/.dy")
11
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
12
+ COOKIES_DIR = os.path.join(CONFIG_DIR, "cookies")
13
+
14
+ DEFAULT_CONFIG: dict[str, Any] = {
15
+ "api": {
16
+ "cookie_file": os.path.join(COOKIES_DIR, "default.json"),
17
+ "proxy": "",
18
+ "timeout": 30,
19
+ },
20
+ "playwright": {
21
+ "headless": False,
22
+ "chromium_path": "",
23
+ "slow_mo": 0,
24
+ },
25
+ "default": {
26
+ "account": "default",
27
+ "engine": "auto", # auto | api | playwright
28
+ "output": "table", # table | json
29
+ "download_dir": os.path.expanduser("~/Downloads/douyin"),
30
+ },
31
+ }
32
+
33
+
34
+ def _ensure_dir():
35
+ os.makedirs(CONFIG_DIR, exist_ok=True)
36
+ os.makedirs(COOKIES_DIR, exist_ok=True)
37
+
38
+
39
+ def load_config() -> dict[str, Any]:
40
+ """加载配置文件,不存在则返回默认配置。"""
41
+ if os.path.exists(CONFIG_FILE):
42
+ try:
43
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
44
+ user_cfg = json.load(f)
45
+ return _deep_merge(DEFAULT_CONFIG, user_cfg)
46
+ except Exception:
47
+ pass
48
+ return dict(DEFAULT_CONFIG)
49
+
50
+
51
+ def save_config(cfg: dict[str, Any]):
52
+ """保存配置到文件。"""
53
+ _ensure_dir()
54
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
55
+ json.dump(cfg, f, ensure_ascii=False, indent=2)
56
+
57
+
58
+ def get(key_path: str, default: Any = None) -> Any:
59
+ """获取嵌套配置值。key_path 格式: 'api.proxy', 'default.engine'"""
60
+ cfg = load_config()
61
+ keys = key_path.split(".")
62
+ current = cfg
63
+ for k in keys:
64
+ if isinstance(current, dict) and k in current:
65
+ current = current[k]
66
+ else:
67
+ return default
68
+ return current
69
+
70
+
71
+ def set_value(key_path: str, value: Any):
72
+ """设置嵌套配置值。"""
73
+ cfg = load_config()
74
+ keys = key_path.split(".")
75
+ current = cfg
76
+ for k in keys[:-1]:
77
+ if k not in current or not isinstance(current[k], dict):
78
+ current[k] = {}
79
+ current = current[k]
80
+ current[keys[-1]] = value
81
+ save_config(cfg)
82
+
83
+
84
+ def get_cookie_file(account: str | None = None) -> str:
85
+ """获取指定账号的 Cookie 文件路径。"""
86
+ _ensure_dir()
87
+ account = account or load_config()["default"]["account"]
88
+ return os.path.join(COOKIES_DIR, f"{account}.json")
89
+
90
+
91
+ def _deep_merge(base: dict, override: dict) -> dict:
92
+ """递归合并字典。"""
93
+ result = dict(base)
94
+ for k, v in override.items():
95
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
96
+ result[k] = _deep_merge(result[k], v)
97
+ else:
98
+ result[k] = v
99
+ return result
@@ -0,0 +1,49 @@
1
+ """
2
+ 统一输出信封 — Agent 友好的结构化输出。
3
+
4
+ 所有 --json / --yaml 输出使用此信封格式:
5
+ 成功: {ok: true, schema_version: "1", data: ...}
6
+ 失败: {ok: false, schema_version: "1", error: {code: ..., message: ...}}
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any
13
+
14
+ SCHEMA_VERSION = "1"
15
+
16
+
17
+ def success_envelope(data: Any) -> dict:
18
+ return {"ok": True, "schema_version": SCHEMA_VERSION, "data": data}
19
+
20
+
21
+ def error_envelope(code: str, message: str) -> dict:
22
+ return {
23
+ "ok": False,
24
+ "schema_version": SCHEMA_VERSION,
25
+ "error": {"code": code, "message": message},
26
+ }
27
+
28
+
29
+ def emit(envelope: dict, fmt: str = "auto") -> None:
30
+ """输出信封到 stdout。
31
+
32
+ fmt: "json" | "yaml" | "auto"
33
+ auto: TTY → 不输出(由 Rich 处理), 非 TTY → yaml
34
+ """
35
+ if fmt == "auto":
36
+ if not sys.stdout.isatty():
37
+ fmt = "yaml"
38
+ else:
39
+ return # TTY 模式由 Rich 处理
40
+
41
+ if fmt == "yaml":
42
+ try:
43
+ import yaml
44
+ sys.stdout.write(yaml.dump(envelope, allow_unicode=True, default_flow_style=False, sort_keys=False))
45
+ except ImportError:
46
+ # fallback to json if pyyaml not installed
47
+ sys.stdout.write(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n")
48
+ else:
49
+ sys.stdout.write(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n")
dy_cli/utils/export.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ 导出工具 — 将列表数据导出为 JSON / CSV 文件。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import csv
7
+ import json
8
+ import os
9
+ from typing import Any
10
+
11
+ from dy_cli.utils.output import success, info
12
+
13
+
14
+ def export_data(data: list[dict], output_path: str) -> None:
15
+ """根据文件扩展名导出数据。"""
16
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
17
+ ext = os.path.splitext(output_path)[1].lower()
18
+
19
+ if ext == ".csv":
20
+ _export_csv(data, output_path)
21
+ elif ext in (".json", ".jsonl"):
22
+ _export_json(data, output_path)
23
+ elif ext in (".yaml", ".yml"):
24
+ _export_yaml(data, output_path)
25
+ else:
26
+ _export_json(data, output_path)
27
+
28
+ success(f"已导出 {len(data)} 条到 {output_path}")
29
+
30
+
31
+ def _export_json(data: list[dict], path: str) -> None:
32
+ with open(path, "w", encoding="utf-8") as f:
33
+ json.dump(data, f, ensure_ascii=False, indent=2)
34
+
35
+
36
+ def _export_csv(data: list[dict], path: str) -> None:
37
+ if not data:
38
+ return
39
+ # Flatten nested dicts for CSV
40
+ flat = [_flatten(item) for item in data]
41
+ keys = list(flat[0].keys())
42
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
43
+ writer = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
44
+ writer.writeheader()
45
+ writer.writerows(flat)
46
+
47
+
48
+ def _export_yaml(data: list[dict], path: str) -> None:
49
+ try:
50
+ import yaml
51
+ with open(path, "w", encoding="utf-8") as f:
52
+ yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
53
+ except ImportError:
54
+ _export_json(data, path)
55
+
56
+
57
+ def _flatten(d: dict, prefix: str = "") -> dict:
58
+ """将嵌套 dict 展平为单层 (用于 CSV)。"""
59
+ items: dict[str, Any] = {}
60
+ for k, v in d.items():
61
+ key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
62
+ if isinstance(v, dict):
63
+ items.update(_flatten(v, key))
64
+ elif isinstance(v, list):
65
+ items[key] = str(v)[:100]
66
+ else:
67
+ items[key] = v
68
+ return items
@@ -0,0 +1,83 @@
1
+ """
2
+ 短索引导航 — 列表命令自动缓存结果,后续命令用数字引用。
3
+
4
+ 用法:
5
+ dy search "美食" → 缓存搜索结果
6
+ dy read 1 → 读取第 1 条
7
+ dy download 3 → 下载第 3 条
8
+ dy detail 2 → 查看第 2 条详情
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from typing import Any
15
+
16
+ from dy_cli.utils.config import CONFIG_DIR
17
+
18
+ INDEX_FILE = os.path.join(CONFIG_DIR, "index_cache.json")
19
+
20
+
21
+ def save_index(items: list[dict[str, str]]) -> None:
22
+ """保存列表结果的索引。每条记录需要 aweme_id。"""
23
+ os.makedirs(os.path.dirname(INDEX_FILE), exist_ok=True)
24
+ entries = []
25
+ for item in items:
26
+ aweme_id = item.get("aweme_id", "")
27
+ if aweme_id:
28
+ entries.append({
29
+ "aweme_id": str(aweme_id),
30
+ "desc": item.get("desc", "")[:60],
31
+ "author": item.get("author", {}).get("nickname", "") if isinstance(item.get("author"), dict) else str(item.get("author", "")),
32
+ "sec_uid": item.get("author", {}).get("sec_uid", "") if isinstance(item.get("author"), dict) else "",
33
+ })
34
+ with open(INDEX_FILE, "w", encoding="utf-8") as f:
35
+ json.dump(entries, f, ensure_ascii=False, indent=2)
36
+
37
+
38
+ def get_by_index(index: int) -> dict[str, str] | None:
39
+ """用 1-based 索引获取缓存条目。"""
40
+ if index <= 0:
41
+ return None
42
+ if not os.path.isfile(INDEX_FILE):
43
+ return None
44
+ try:
45
+ with open(INDEX_FILE, "r", encoding="utf-8") as f:
46
+ data = json.load(f)
47
+ if not isinstance(data, list) or index > len(data):
48
+ return None
49
+ return data[index - 1]
50
+ except (json.JSONDecodeError, OSError):
51
+ return None
52
+
53
+
54
+ def resolve_id(id_or_index: str) -> str:
55
+ """解析 aweme_id: 短数字(≤999)视为索引,长数字视为 ID,含字母/URL 原样返回。"""
56
+ if not id_or_index.isdigit():
57
+ return id_or_index
58
+
59
+ n = int(id_or_index)
60
+ # 短索引: 1-999; aweme_id 通常 > 15 位
61
+ if n <= 999:
62
+ entry = get_by_index(n)
63
+ if entry:
64
+ return entry["aweme_id"]
65
+ # 索引不存在时给出明确提示,不要把 "1" 当 aweme_id
66
+ count = get_index_count()
67
+ if count == 0:
68
+ raise ValueError("没有缓存的搜索结果,请先执行 dy search")
69
+ raise ValueError(f"索引 {n} 超出范围 (共 {count} 条)")
70
+
71
+ return id_or_index
72
+
73
+
74
+ def get_index_count() -> int:
75
+ """获取缓存的条目数量。"""
76
+ if not os.path.isfile(INDEX_FILE):
77
+ return 0
78
+ try:
79
+ with open(INDEX_FILE, "r", encoding="utf-8") as f:
80
+ data = json.load(f)
81
+ return len(data) if isinstance(data, list) else 0
82
+ except (json.JSONDecodeError, OSError):
83
+ return 0