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/utils/output.py ADDED
@@ -0,0 +1,283 @@
1
+ """
2
+ 统一输出格式化 — 表格、JSON、状态信息。
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+ from rich import box
14
+
15
+ console = Console()
16
+ err_console = Console(stderr=True)
17
+
18
+
19
+ # ------------------------------------------------------------------
20
+ # 基础状态输出
21
+ # ------------------------------------------------------------------
22
+
23
+ def success(msg: str):
24
+ console.print(f"[bold green]✓[/] {msg}")
25
+
26
+
27
+ def error(msg: str):
28
+ err_console.print(f"[bold red]✗[/] {msg}")
29
+
30
+
31
+ def warning(msg: str):
32
+ console.print(f"[bold yellow]⚠[/] {msg}")
33
+
34
+
35
+ def info(msg: str):
36
+ console.print(f"[dim]ℹ[/] {msg}")
37
+
38
+
39
+ def status(label: str, value: str, style: str = ""):
40
+ if style:
41
+ console.print(f" [bold]{label}:[/] [{style}]{value}[/]")
42
+ else:
43
+ console.print(f" [bold]{label}:[/] {value}")
44
+
45
+
46
+ def print_json(data: Any, envelope: bool = True):
47
+ """输出 JSON。envelope=True 时包裹在统一信封中。"""
48
+ from dy_cli.utils.envelope import success_envelope
49
+ output = success_envelope(data) if envelope else data
50
+ console.print_json(json.dumps(output, ensure_ascii=False, indent=2))
51
+
52
+
53
+ def print_table(
54
+ title: str,
55
+ columns: list[str],
56
+ rows: list[list[str]],
57
+ max_width: int | None = None,
58
+ ):
59
+ table = Table(title=title, box=box.ROUNDED, show_lines=True, expand=False)
60
+ for col in columns:
61
+ table.add_column(col, overflow="fold", max_width=max_width or 40)
62
+ for row in rows:
63
+ table.add_row(*[str(v) for v in row])
64
+ console.print(table)
65
+
66
+
67
+ # ------------------------------------------------------------------
68
+ # 抖音专属格式化
69
+ # ------------------------------------------------------------------
70
+
71
+ def _fmt_count(n: Any) -> str:
72
+ """格式化数字,支持 '10.5万' 等。"""
73
+ if n is None or n == "":
74
+ return "-"
75
+ if isinstance(n, str):
76
+ return n
77
+ if isinstance(n, (int, float)):
78
+ if n >= 10000:
79
+ return f"{n / 10000:.1f}万"
80
+ return str(int(n))
81
+ return str(n)
82
+
83
+
84
+ def print_videos(videos: list[dict], keyword: str = ""):
85
+ """打印视频搜索结果列表。"""
86
+ if not videos:
87
+ warning("未找到相关视频")
88
+ return
89
+
90
+ title = f"搜索结果: {keyword} ({len(videos)} 条)" if keyword else f"视频列表 ({len(videos)} 条)"
91
+ table = Table(title=title, box=box.ROUNDED, show_lines=True)
92
+ table.add_column("#", style="dim", width=3)
93
+ table.add_column("标题", max_width=30, overflow="fold")
94
+ table.add_column("作者", max_width=12, overflow="fold")
95
+ table.add_column("播放", justify="right", width=8)
96
+ table.add_column("点赞", justify="right", width=8)
97
+ table.add_column("评论", justify="right", width=8)
98
+ table.add_column("类型", width=6)
99
+ table.add_column("aweme_id", style="dim", max_width=22)
100
+
101
+ for i, v in enumerate(videos, 1):
102
+ desc = v.get("desc", "") or "-"
103
+ author = v.get("author", {}).get("nickname", "-")
104
+ stats = v.get("statistics", {})
105
+ play = _fmt_count(stats.get("play_count", stats.get("digg_count", "-")))
106
+ likes = _fmt_count(stats.get("digg_count", "-"))
107
+ comments = _fmt_count(stats.get("comment_count", "-"))
108
+ vtype = "图文" if v.get("media_type") == 2 else "视频"
109
+ aweme_id = v.get("aweme_id", "-")
110
+
111
+ # Truncate desc
112
+ if len(desc) > 30:
113
+ desc = desc[:28] + "…"
114
+
115
+ table.add_row(str(i), desc, author, play, likes, comments, vtype, aweme_id)
116
+
117
+ console.print(table)
118
+
119
+
120
+ def print_video_detail(detail: dict):
121
+ """打印单个视频详情面板。"""
122
+ desc = detail.get("desc", "无描述")
123
+ author = detail.get("author", {})
124
+ nickname = author.get("nickname", "-")
125
+ uid = author.get("unique_id") or author.get("short_id") or "-"
126
+ stats = detail.get("statistics", {})
127
+ create_time = detail.get("create_time", "")
128
+
129
+ panel_text = Text()
130
+ panel_text.append(f"作者: {nickname}", style="bold")
131
+ panel_text.append(f" (@{uid})\n")
132
+ if create_time:
133
+ import datetime
134
+ try:
135
+ ts = int(create_time)
136
+ dt = datetime.datetime.fromtimestamp(ts)
137
+ panel_text.append(f"发布: {dt.strftime('%Y-%m-%d %H:%M')}\n")
138
+ except (ValueError, TypeError, OSError):
139
+ pass
140
+ panel_text.append(
141
+ f"▶ {_fmt_count(stats.get('play_count', '-'))} "
142
+ f"👍 {_fmt_count(stats.get('digg_count', '-'))} "
143
+ f"💬 {_fmt_count(stats.get('comment_count', '-'))} "
144
+ f"↗ {_fmt_count(stats.get('share_count', '-'))} "
145
+ f"⭐ {_fmt_count(stats.get('collect_count', '-'))}\n"
146
+ )
147
+ panel_text.append(f"\n{desc}\n")
148
+
149
+ aweme_id = detail.get("aweme_id", "")
150
+ console.print(Panel(panel_text, title=f"🎬 {aweme_id}", border_style="blue"))
151
+
152
+
153
+ def print_comments(comments: list[dict]):
154
+ """打印评论列表。"""
155
+ if not comments:
156
+ info("暂无评论")
157
+ return
158
+
159
+ table = Table(title=f"💬 评论 ({len(comments)} 条)", box=box.ROUNDED, show_lines=True)
160
+ table.add_column("#", style="dim", width=3)
161
+ table.add_column("用户", max_width=14, overflow="fold")
162
+ table.add_column("内容", max_width=45, overflow="fold")
163
+ table.add_column("赞", justify="right", width=6)
164
+ table.add_column("回复", justify="right", width=5)
165
+
166
+ for i, c in enumerate(comments, 1):
167
+ user = c.get("user", {})
168
+ table.add_row(
169
+ str(i),
170
+ user.get("nickname", "-"),
171
+ c.get("text", "-"),
172
+ _fmt_count(c.get("digg_count", "-")),
173
+ _fmt_count(c.get("reply_comment_total", 0)),
174
+ )
175
+
176
+ console.print(table)
177
+
178
+
179
+ def print_trending(items: list[dict]):
180
+ """打印热榜。"""
181
+ if not items:
182
+ warning("暂无热榜数据")
183
+ return
184
+
185
+ table = Table(title="🔥 抖音热榜", box=box.ROUNDED, show_lines=True)
186
+ table.add_column("#", style="bold", width=4, justify="right")
187
+ table.add_column("标题", max_width=40, overflow="fold")
188
+ table.add_column("热度", justify="right", width=10)
189
+ table.add_column("标签", width=10)
190
+
191
+ LABEL_MAP = {0: "", 1: "新", 2: "热", 3: "爆", 4: "独家"}
192
+
193
+ for i, item in enumerate(items, 1):
194
+ title = item.get("word", item.get("title", "-"))
195
+ hot = _fmt_count(item.get("hot_value", item.get("view_count", "-")))
196
+ raw_label = item.get("label", item.get("tag", ""))
197
+ if isinstance(raw_label, int):
198
+ label = LABEL_MAP.get(raw_label, str(raw_label))
199
+ else:
200
+ label = str(raw_label)
201
+
202
+ # Top 3 colored
203
+ rank_style = "bold red" if i <= 3 else "dim"
204
+ table.add_row(
205
+ Text(str(i), style=rank_style),
206
+ str(title),
207
+ str(hot),
208
+ label,
209
+ )
210
+
211
+ console.print(table)
212
+
213
+
214
+ def print_live_info(info_data: dict):
215
+ """打印直播信息面板。"""
216
+ title = info_data.get("title", "直播间")
217
+ owner = info_data.get("owner", {})
218
+ nickname = owner.get("nickname", "-")
219
+ user_count = _fmt_count(info_data.get("user_count", "-"))
220
+ status_val = "🟢 直播中" if info_data.get("status") == 2 else "⚫ 已结束"
221
+
222
+ panel_text = Text()
223
+ panel_text.append(f"主播: {nickname}\n", style="bold")
224
+ panel_text.append(f"状态: {status_val}\n")
225
+ panel_text.append(f"在线: {user_count}\n")
226
+
227
+ stream_url = info_data.get("stream_url", "")
228
+ if stream_url:
229
+ panel_text.append(f"\n拉流: {stream_url[:80]}…\n" if len(stream_url) > 80 else f"\n拉流: {stream_url}\n")
230
+
231
+ console.print(Panel(panel_text, title=f"📺 {title}", border_style="magenta"))
232
+
233
+
234
+ def print_user_profile(profile: dict):
235
+ """打印用户资料。"""
236
+ nickname = profile.get("nickname", "-")
237
+ unique_id = profile.get("unique_id") or profile.get("short_id") or "-"
238
+ signature = profile.get("signature", "")
239
+ follower = _fmt_count(profile.get("follower_count", "-"))
240
+ following = _fmt_count(profile.get("following_count", "-"))
241
+ total_favorited = _fmt_count(profile.get("total_favorited", "-"))
242
+ aweme_count = profile.get("aweme_count", "-")
243
+
244
+ panel_text = Text()
245
+ panel_text.append(f"昵称: {nickname}", style="bold")
246
+ panel_text.append(f" @{unique_id}\n")
247
+ panel_text.append(f"粉丝: {follower} 关注: {following} 获赞: {total_favorited} 作品: {aweme_count}\n")
248
+ if signature:
249
+ panel_text.append(f"\n{signature}")
250
+
251
+ console.print(Panel(panel_text, title="👤 用户资料", border_style="green"))
252
+
253
+
254
+ def print_analytics(data: dict):
255
+ """打印数据看板。"""
256
+ rows = data.get("rows", [])
257
+ if not rows:
258
+ warning("暂无数据")
259
+ return
260
+
261
+ table = Table(title="📊 数据看板", box=box.ROUNDED, show_lines=True)
262
+ table.add_column("标题", max_width=20, overflow="fold")
263
+ table.add_column("发布时间", width=16)
264
+ table.add_column("播放", justify="right", width=8)
265
+ table.add_column("完播率", justify="right", width=8)
266
+ table.add_column("点赞", justify="right", width=8)
267
+ table.add_column("评论", justify="right", width=8)
268
+ table.add_column("分享", justify="right", width=8)
269
+ table.add_column("涨粉", justify="right", width=8)
270
+
271
+ for row in rows:
272
+ table.add_row(
273
+ str(row.get("标题", "-")),
274
+ str(row.get("发布时间", "-")),
275
+ str(row.get("播放", "-")),
276
+ str(row.get("完播率", "-")),
277
+ str(row.get("点赞", "-")),
278
+ str(row.get("评论", "-")),
279
+ str(row.get("分享", "-")),
280
+ str(row.get("涨粉", "-")),
281
+ )
282
+
283
+ console.print(table)
@@ -0,0 +1,183 @@
1
+ """
2
+ 抖音签名工具 — 通过 Playwright 在浏览器内执行签名 JS。
3
+
4
+ 抖音 API 请求需要 a-bogus / x-bogus 签名参数。
5
+ 本模块使用 Playwright 启动浏览器执行签名计算,避免 Node.js 额外依赖。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ import random
13
+ import string
14
+ import time
15
+ from typing import Any
16
+ from urllib.parse import urlencode
17
+
18
+
19
+ # ------------------------------------------------------------------
20
+ # 基础参数生成(不需要 JS 签名的请求)
21
+ # ------------------------------------------------------------------
22
+
23
+ def generate_device_id() -> str:
24
+ """生成随机设备 ID。"""
25
+ return "".join(random.choices(string.digits, k=19))
26
+
27
+
28
+ def generate_iid() -> str:
29
+ """生成随机 install_id。"""
30
+ return "".join(random.choices(string.digits, k=19))
31
+
32
+
33
+ def get_ms_token(length: int = 128) -> str:
34
+ """生成随机 msToken。"""
35
+ chars = string.ascii_letters + string.digits
36
+ return "".join(random.choices(chars, k=length))
37
+
38
+
39
+ def get_base_params() -> dict[str, str]:
40
+ """获取抖音 Web 端基础请求参数。"""
41
+ return {
42
+ "device_platform": "webapp",
43
+ "aid": "6383",
44
+ "channel": "channel_pc_web",
45
+ "pc_client_type": "1",
46
+ "version_code": "170400",
47
+ "version_name": "17.4.0",
48
+ "cookie_enabled": "true",
49
+ "screen_width": "1920",
50
+ "screen_height": "1080",
51
+ "browser_language": "zh-CN",
52
+ "browser_platform": "MacIntel",
53
+ "browser_name": "Chrome",
54
+ "browser_version": "120.0.0.0",
55
+ "browser_online": "true",
56
+ "engine_name": "Blink",
57
+ "engine_version": "120.0.0.0",
58
+ "os_name": "Mac OS",
59
+ "os_version": "10.15.7",
60
+ "cpu_core_num": "8",
61
+ "device_memory": "8",
62
+ "platform": "PC",
63
+ "downlink": "10",
64
+ "effective_type": "4g",
65
+ "round_trip_time": "50",
66
+ "msToken": get_ms_token(),
67
+ }
68
+
69
+
70
+ def build_request_url(base_url: str, params: dict[str, str]) -> str:
71
+ """构建完整请求 URL。"""
72
+ return f"{base_url}?{urlencode(params)}"
73
+
74
+
75
+ # ------------------------------------------------------------------
76
+ # Web 端请求头
77
+ # ------------------------------------------------------------------
78
+
79
+ USER_AGENTS = [
80
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
81
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
82
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
83
+ ]
84
+
85
+
86
+ def get_headers(cookie: str = "", referer: str = "https://www.douyin.com/") -> dict[str, str]:
87
+ """获取抖音 Web 端请求头。"""
88
+ headers = {
89
+ "Accept": "application/json, text/plain, */*",
90
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
91
+ "Cache-Control": "no-cache",
92
+ "Pragma": "no-cache",
93
+ "Referer": referer,
94
+ "User-Agent": random.choice(USER_AGENTS),
95
+ }
96
+ if cookie:
97
+ headers["Cookie"] = cookie
98
+ return headers
99
+
100
+
101
+ # ------------------------------------------------------------------
102
+ # Playwright 签名(异步)
103
+ # ------------------------------------------------------------------
104
+
105
+ _SIGN_PAGE = None # cache browser page
106
+
107
+
108
+ async def get_sign_page():
109
+ """获取或创建用于签名的 Playwright 页面(单例缓存)。"""
110
+ global _SIGN_PAGE
111
+ if _SIGN_PAGE and not _SIGN_PAGE.is_closed():
112
+ return _SIGN_PAGE
113
+
114
+ from playwright.async_api import async_playwright
115
+ pw = await async_playwright().start()
116
+ browser = await pw.chromium.launch(headless=True)
117
+ context = await browser.new_context()
118
+ page = await context.new_page()
119
+ await page.goto("https://www.douyin.com/", wait_until="domcontentloaded")
120
+ # Wait a bit for JS to load
121
+ await page.wait_for_timeout(2000)
122
+ _SIGN_PAGE = page
123
+ return page
124
+
125
+
126
+ async def sign_url_async(url: str) -> str:
127
+ """
128
+ 使用 Playwright 浏览器内签名 URL。
129
+
130
+ 在已加载的抖音页面中执行签名 JS,获取 x-bogus / a-bogus 参数。
131
+ """
132
+ try:
133
+ page = await get_sign_page()
134
+ # 尝试调用抖音内置签名函数
135
+ signed = await page.evaluate(
136
+ """(url) => {
137
+ try {
138
+ // 抖音 Web 端内置的签名函数
139
+ if (window._webmsxyw) {
140
+ return window._webmsxyw(url);
141
+ }
142
+ if (window.byted_acrawler && window.byted_acrawler.sign) {
143
+ return window.byted_acrawler.sign({url: url});
144
+ }
145
+ } catch(e) {}
146
+ return null;
147
+ }""",
148
+ url,
149
+ )
150
+ if signed and isinstance(signed, dict):
151
+ x_bogus = signed.get("X-Bogus", "")
152
+ if x_bogus:
153
+ separator = "&" if "?" in url else "?"
154
+ return f"{url}{separator}X-Bogus={x_bogus}"
155
+ return url
156
+ except Exception:
157
+ return url
158
+
159
+
160
+ def sign_url(url: str) -> str:
161
+ """同步版本的 URL 签名。"""
162
+ try:
163
+ loop = asyncio.get_event_loop()
164
+ if loop.is_running():
165
+ return url # fallback in async context
166
+ return loop.run_until_complete(sign_url_async(url))
167
+ except RuntimeError:
168
+ loop = asyncio.new_event_loop()
169
+ try:
170
+ return loop.run_until_complete(sign_url_async(url))
171
+ finally:
172
+ loop.close()
173
+
174
+
175
+ async def close_sign_page():
176
+ """关闭签名页面,释放资源。"""
177
+ global _SIGN_PAGE
178
+ if _SIGN_PAGE and not _SIGN_PAGE.is_closed():
179
+ browser = _SIGN_PAGE.context.browser
180
+ await _SIGN_PAGE.close()
181
+ if browser:
182
+ await browser.close()
183
+ _SIGN_PAGE = None