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/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
|