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
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Douyin API Client — 逆向 API 采集客户端。
|
|
3
|
+
|
|
4
|
+
通过 httpx 调用抖音 Web 端接口,实现搜索、下载、评论、热榜等功能。
|
|
5
|
+
参考: JoeanAmier/TikTokDownloader, Evil0ctal/Douyin_TikTok_Download_API
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
from dy_cli.utils.signature import (
|
|
22
|
+
get_base_params,
|
|
23
|
+
get_headers,
|
|
24
|
+
get_ms_token,
|
|
25
|
+
build_request_url,
|
|
26
|
+
)
|
|
27
|
+
from dy_cli.utils import config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Constants
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
DOUYIN_DOMAIN = "https://www.douyin.com"
|
|
35
|
+
API_DOMAIN = "https://www.douyin.com"
|
|
36
|
+
|
|
37
|
+
# API endpoints
|
|
38
|
+
SEARCH_URL = f"{API_DOMAIN}/aweme/v1/web/general/search/single/"
|
|
39
|
+
VIDEO_DETAIL_URL = f"{API_DOMAIN}/aweme/v1/web/aweme/detail/"
|
|
40
|
+
VIDEO_COMMENTS_URL = f"{API_DOMAIN}/aweme/v1/web/comment/list/"
|
|
41
|
+
USER_PROFILE_URL = f"{API_DOMAIN}/aweme/v1/web/user/profile/other/"
|
|
42
|
+
USER_POSTS_URL = f"{API_DOMAIN}/aweme/v1/web/aweme/post/"
|
|
43
|
+
TRENDING_URL = f"{API_DOMAIN}/aweme/v1/web/hot/search/list/"
|
|
44
|
+
LIVE_INFO_URL = "https://live.douyin.com/webcast/room/web/enter/"
|
|
45
|
+
FEED_URL = f"{API_DOMAIN}/aweme/v1/web/tab/feed/"
|
|
46
|
+
SUGGEST_URL = f"{API_DOMAIN}/aweme/v1/web/api/suggest_words/"
|
|
47
|
+
|
|
48
|
+
# iesdouyin API (share API, more stable, less anti-crawl)
|
|
49
|
+
IESDOUYIN_DETAIL_URL = "https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/"
|
|
50
|
+
|
|
51
|
+
# ttwid registration
|
|
52
|
+
TTWID_URL = "https://ttwid.bytedance.com/ttwid/union/register/"
|
|
53
|
+
|
|
54
|
+
# Share URL pattern
|
|
55
|
+
SHARE_URL_PATTERN = re.compile(
|
|
56
|
+
r"https?://(?:www\.)?(?:douyin\.com|iesdouyin\.com)/(?:video|note|share/video)/(\d+)"
|
|
57
|
+
)
|
|
58
|
+
SHORT_URL_PATTERN = re.compile(r"https?://v\.douyin\.com/\w+/?")
|
|
59
|
+
|
|
60
|
+
REQUEST_TIMEOUT = 30
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DouyinAPIError(Exception):
|
|
64
|
+
"""抖音 API 调用错误。"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DouyinAPIClient:
|
|
68
|
+
"""
|
|
69
|
+
抖音 Web 端 API 客户端。
|
|
70
|
+
|
|
71
|
+
通过逆向 Web 端接口实现数据采集。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
cookie: str = "",
|
|
77
|
+
proxy: str = "",
|
|
78
|
+
timeout: int = REQUEST_TIMEOUT,
|
|
79
|
+
):
|
|
80
|
+
self.cookie = cookie
|
|
81
|
+
self.proxy = proxy
|
|
82
|
+
self.timeout = timeout
|
|
83
|
+
self._client: httpx.Client | None = None
|
|
84
|
+
self._last_request_time: float = 0.0
|
|
85
|
+
self._request_delay: float = 1.0
|
|
86
|
+
self._base_delay: float = 1.0
|
|
87
|
+
self._verify_count: int = 0
|
|
88
|
+
self._max_retries: int = 3
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# HTTP client
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def client(self) -> httpx.Client:
|
|
96
|
+
if self._client is None:
|
|
97
|
+
transport_kwargs = {}
|
|
98
|
+
if self.proxy:
|
|
99
|
+
transport_kwargs["proxy"] = self.proxy
|
|
100
|
+
self._client = httpx.Client(
|
|
101
|
+
timeout=self.timeout,
|
|
102
|
+
follow_redirects=True,
|
|
103
|
+
**transport_kwargs,
|
|
104
|
+
)
|
|
105
|
+
self._init_cookies()
|
|
106
|
+
return self._client
|
|
107
|
+
|
|
108
|
+
def _init_cookies(self):
|
|
109
|
+
"""获取 ttwid 等必要 cookie。"""
|
|
110
|
+
try:
|
|
111
|
+
self._client.post(
|
|
112
|
+
TTWID_URL,
|
|
113
|
+
json={
|
|
114
|
+
"region": "cn",
|
|
115
|
+
"aid": 1768,
|
|
116
|
+
"needFid": False,
|
|
117
|
+
"service": "www.douyin.com",
|
|
118
|
+
"migrate_info": {"ticket": "", "source": "node"},
|
|
119
|
+
"cbUrlProtocol": "https",
|
|
120
|
+
"union": True,
|
|
121
|
+
},
|
|
122
|
+
headers={"Content-Type": "application/json"},
|
|
123
|
+
)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Rate limiting & anti-detection
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _rate_limit_delay(self) -> None:
|
|
132
|
+
"""高斯抖动延迟,模拟人类浏览节奏。"""
|
|
133
|
+
if self._request_delay <= 0:
|
|
134
|
+
return
|
|
135
|
+
elapsed = time.time() - self._last_request_time
|
|
136
|
+
if elapsed < self._request_delay:
|
|
137
|
+
jitter = max(0, random.gauss(0.3, 0.15))
|
|
138
|
+
# 5% 概率增加长停顿(模拟阅读行为)
|
|
139
|
+
if random.random() < 0.05:
|
|
140
|
+
jitter += random.uniform(2.0, 5.0)
|
|
141
|
+
sleep_time = self._request_delay - elapsed + jitter
|
|
142
|
+
logger.debug("Rate-limit delay: %.2fs", sleep_time)
|
|
143
|
+
time.sleep(sleep_time)
|
|
144
|
+
|
|
145
|
+
def _handle_verify(self, resp: httpx.Response) -> None:
|
|
146
|
+
"""验证码冷却:渐进式退避。"""
|
|
147
|
+
self._verify_count += 1
|
|
148
|
+
cooldown = min(30, 5 * (2 ** (self._verify_count - 1)))
|
|
149
|
+
logger.warning("Verify triggered (count=%d), cooldown %.0fs", self._verify_count, cooldown)
|
|
150
|
+
self._request_delay = max(self._request_delay, self._base_delay * 2)
|
|
151
|
+
time.sleep(cooldown)
|
|
152
|
+
|
|
153
|
+
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
154
|
+
"""带重试和退避的请求。"""
|
|
155
|
+
self._rate_limit_delay()
|
|
156
|
+
last_exc: Exception | None = None
|
|
157
|
+
|
|
158
|
+
for attempt in range(self._max_retries):
|
|
159
|
+
try:
|
|
160
|
+
resp = self.client.request(method, url, **kwargs)
|
|
161
|
+
self._last_request_time = time.time()
|
|
162
|
+
|
|
163
|
+
# 重试: 429 / 5xx
|
|
164
|
+
if resp.status_code in (429, 500, 502, 503, 504):
|
|
165
|
+
wait = (2 ** attempt) + random.uniform(0, 1)
|
|
166
|
+
logger.warning("HTTP %d, retry in %.1fs (%d/%d)", resp.status_code, wait, attempt + 1, self._max_retries)
|
|
167
|
+
time.sleep(wait)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
self._verify_count = 0
|
|
171
|
+
return resp
|
|
172
|
+
|
|
173
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
174
|
+
last_exc = exc
|
|
175
|
+
wait = (2 ** attempt) + random.uniform(0, 1)
|
|
176
|
+
logger.warning("Network error: %s, retry in %.1fs (%d/%d)", exc, wait, attempt + 1, self._max_retries)
|
|
177
|
+
time.sleep(wait)
|
|
178
|
+
|
|
179
|
+
if last_exc:
|
|
180
|
+
raise DouyinAPIError(f"请求失败 ({self._max_retries} 次重试后): {last_exc}") from last_exc
|
|
181
|
+
raise DouyinAPIError(f"请求失败: HTTP {resp.status_code}")
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# HTTP methods
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _get(self, url: str, params: dict | None = None, **kwargs) -> dict:
|
|
188
|
+
"""GET 请求,带重试和反爬。"""
|
|
189
|
+
headers = get_headers(cookie=self.cookie)
|
|
190
|
+
resp = self._request_with_retry("GET", url, params=params, headers=headers, **kwargs)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
except httpx.HTTPStatusError as e:
|
|
195
|
+
raise DouyinAPIError(f"HTTP {e.response.status_code}: {url}") from e
|
|
196
|
+
|
|
197
|
+
if not resp.content:
|
|
198
|
+
raise DouyinAPIError(f"空响应 (可能需要登录或签名): {url.split('/')[-2]}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
data = resp.json()
|
|
202
|
+
except json.JSONDecodeError as e:
|
|
203
|
+
raise DouyinAPIError(f"JSON 解析失败: {e}") from e
|
|
204
|
+
|
|
205
|
+
# 检测 verify_check — 只记录,不重试(避免死循环)
|
|
206
|
+
nil_info = data.get("search_nil_info", {})
|
|
207
|
+
if nil_info.get("search_nil_type") == "verify_check":
|
|
208
|
+
self._verify_count += 1
|
|
209
|
+
logger.warning("verify_check detected (count=%d)", self._verify_count)
|
|
210
|
+
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
def _post(self, url: str, data: dict | None = None, **kwargs) -> dict:
|
|
214
|
+
"""POST 请求,带重试和反爬。"""
|
|
215
|
+
headers = get_headers(cookie=self.cookie)
|
|
216
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
217
|
+
resp = self._request_with_retry("POST", url, data=data, headers=headers, **kwargs)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
resp.raise_for_status()
|
|
221
|
+
except httpx.HTTPStatusError as e:
|
|
222
|
+
raise DouyinAPIError(f"HTTP {e.response.status_code}: {url}") from e
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
return resp.json()
|
|
226
|
+
except json.JSONDecodeError as e:
|
|
227
|
+
raise DouyinAPIError(f"JSON 解析失败: {e}") from e
|
|
228
|
+
|
|
229
|
+
def close(self):
|
|
230
|
+
if self._client:
|
|
231
|
+
self._client.close()
|
|
232
|
+
self._client = None
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# Cookie management
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def from_config(cls, account: str | None = None) -> "DouyinAPIClient":
|
|
240
|
+
"""从配置文件创建客户端。"""
|
|
241
|
+
cfg = config.load_config()
|
|
242
|
+
cookie_file = config.get_cookie_file(account)
|
|
243
|
+
cookie = ""
|
|
244
|
+
if os.path.exists(cookie_file):
|
|
245
|
+
try:
|
|
246
|
+
with open(cookie_file, "r", encoding="utf-8") as f:
|
|
247
|
+
cookie_data = json.load(f)
|
|
248
|
+
# Support playwright storage_state format
|
|
249
|
+
if isinstance(cookie_data, dict) and "cookies" in cookie_data:
|
|
250
|
+
cookies = cookie_data["cookies"]
|
|
251
|
+
cookie = "; ".join(
|
|
252
|
+
f"{c['name']}={c['value']}"
|
|
253
|
+
for c in cookies
|
|
254
|
+
if "douyin" in c.get("domain", "")
|
|
255
|
+
)
|
|
256
|
+
elif isinstance(cookie_data, str):
|
|
257
|
+
cookie = cookie_data
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
proxy = cfg["api"].get("proxy", "")
|
|
262
|
+
timeout = cfg["api"].get("timeout", REQUEST_TIMEOUT)
|
|
263
|
+
return cls(cookie=cookie, proxy=proxy, timeout=timeout)
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# URL parsing
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def resolve_share_url(self, url: str) -> str:
|
|
270
|
+
"""从分享链接提取 aweme_id。"""
|
|
271
|
+
# Direct URL
|
|
272
|
+
match = SHARE_URL_PATTERN.search(url)
|
|
273
|
+
if match:
|
|
274
|
+
return match.group(1)
|
|
275
|
+
|
|
276
|
+
# Short URL — follow redirect (don't auto-follow, check 302 location)
|
|
277
|
+
if SHORT_URL_PATTERN.match(url):
|
|
278
|
+
try:
|
|
279
|
+
# Step 1: Don't follow redirects, get 302 Location header
|
|
280
|
+
no_follow = httpx.Client(follow_redirects=False, timeout=self.timeout)
|
|
281
|
+
resp = no_follow.get(url, headers=get_headers())
|
|
282
|
+
no_follow.close()
|
|
283
|
+
|
|
284
|
+
location = resp.headers.get("location", "")
|
|
285
|
+
match = SHARE_URL_PATTERN.search(location)
|
|
286
|
+
if match:
|
|
287
|
+
return match.group(1)
|
|
288
|
+
|
|
289
|
+
# Step 2: If redirected to homepage, try following with full client
|
|
290
|
+
resp2 = self.client.get(url, headers=get_headers())
|
|
291
|
+
final_url = str(resp2.url)
|
|
292
|
+
match = SHARE_URL_PATTERN.search(final_url)
|
|
293
|
+
if match:
|
|
294
|
+
return match.group(1)
|
|
295
|
+
|
|
296
|
+
# Step 3: Search in response body for video ID pattern
|
|
297
|
+
body = resp2.text[:50000]
|
|
298
|
+
match = re.search(r'(?:video|aweme)[/_]?(?:id)?[=:/](\d{15,})', body)
|
|
299
|
+
if match:
|
|
300
|
+
return match.group(1)
|
|
301
|
+
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
# Try extracting numbers that look like aweme_id from the URL itself
|
|
306
|
+
match = re.search(r'/(\d{15,})', url)
|
|
307
|
+
if match:
|
|
308
|
+
return match.group(1)
|
|
309
|
+
|
|
310
|
+
raise DouyinAPIError(f"无法从链接提取视频 ID: {url}")
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# Search
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def search(
|
|
317
|
+
self,
|
|
318
|
+
keyword: str,
|
|
319
|
+
sort_type: int = 0,
|
|
320
|
+
publish_time: int = 0,
|
|
321
|
+
filter_duration: int = 0,
|
|
322
|
+
search_type: str = "general",
|
|
323
|
+
offset: int = 0,
|
|
324
|
+
count: int = 20,
|
|
325
|
+
) -> dict:
|
|
326
|
+
"""
|
|
327
|
+
搜索抖音内容。
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
keyword: 搜索关键词
|
|
331
|
+
sort_type: 0=综合, 1=最多点赞, 2=最新发布
|
|
332
|
+
publish_time: 0=不限, 1=一天内, 7=一周内, 182=半年内
|
|
333
|
+
filter_duration: 0=不限, 1=1分钟内, 2=1-5分钟, 3=5分钟以上
|
|
334
|
+
search_type: general(综合), video, user
|
|
335
|
+
offset: 偏移量
|
|
336
|
+
count: 每页数量
|
|
337
|
+
"""
|
|
338
|
+
params = {
|
|
339
|
+
**get_base_params(),
|
|
340
|
+
"keyword": keyword,
|
|
341
|
+
"search_channel": search_type,
|
|
342
|
+
"sort_type": str(sort_type),
|
|
343
|
+
"publish_time": str(publish_time),
|
|
344
|
+
"filter_duration": str(filter_duration),
|
|
345
|
+
"offset": str(offset),
|
|
346
|
+
"count": str(count),
|
|
347
|
+
"search_source": "normal_search",
|
|
348
|
+
"query_correct_type": "1",
|
|
349
|
+
"is_filter_search": "0",
|
|
350
|
+
}
|
|
351
|
+
data = self._get(SEARCH_URL, params=params)
|
|
352
|
+
|
|
353
|
+
if data.get("status_code") != 0:
|
|
354
|
+
raise DouyinAPIError(
|
|
355
|
+
f"搜索失败: {data.get('status_msg', 'unknown error')}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return data
|
|
359
|
+
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
# Video detail
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def get_video_detail(self, aweme_id: str) -> dict:
|
|
365
|
+
"""获取视频详情(自动 fallback 到 share API)。"""
|
|
366
|
+
# Primary: Web API
|
|
367
|
+
try:
|
|
368
|
+
params = {
|
|
369
|
+
**get_base_params(),
|
|
370
|
+
"aweme_id": aweme_id,
|
|
371
|
+
}
|
|
372
|
+
data = self._get(VIDEO_DETAIL_URL, params=params)
|
|
373
|
+
if data.get("status_code") == 0:
|
|
374
|
+
aweme_detail = data.get("aweme_detail", {})
|
|
375
|
+
if aweme_detail:
|
|
376
|
+
return aweme_detail
|
|
377
|
+
except DouyinAPIError:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
# Fallback: iesdouyin share API (更稳定,无签名要求)
|
|
381
|
+
return self._get_detail_via_share(aweme_id)
|
|
382
|
+
|
|
383
|
+
def _get_detail_via_share(self, aweme_id: str) -> dict:
|
|
384
|
+
"""通过 iesdouyin share 页面 SSR 数据获取详情。"""
|
|
385
|
+
headers = get_headers()
|
|
386
|
+
headers["User-Agent"] = (
|
|
387
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
|
388
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
resp = self.client.get(
|
|
392
|
+
f"https://www.iesdouyin.com/share/video/{aweme_id}/",
|
|
393
|
+
headers=headers,
|
|
394
|
+
)
|
|
395
|
+
resp.raise_for_status()
|
|
396
|
+
text = resp.text
|
|
397
|
+
|
|
398
|
+
# Extract _ROUTER_DATA from SSR page
|
|
399
|
+
idx = text.find("_ROUTER_DATA")
|
|
400
|
+
if idx < 0:
|
|
401
|
+
raise DouyinAPIError(f"无法从分享页提取数据: {aweme_id}")
|
|
402
|
+
|
|
403
|
+
start = text.find("{", idx)
|
|
404
|
+
if start < 0:
|
|
405
|
+
raise DouyinAPIError(f"无法解析分享页数据: {aweme_id}")
|
|
406
|
+
|
|
407
|
+
depth = 0
|
|
408
|
+
end = start
|
|
409
|
+
for i, c in enumerate(text[start:start + 50000]):
|
|
410
|
+
if c == "{":
|
|
411
|
+
depth += 1
|
|
412
|
+
elif c == "}":
|
|
413
|
+
depth -= 1
|
|
414
|
+
if depth == 0:
|
|
415
|
+
end = start + i + 1
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
raw = text[start:end].replace("\\u002F", "/")
|
|
419
|
+
data = json.loads(raw)
|
|
420
|
+
loader = data.get("loaderData", {})
|
|
421
|
+
|
|
422
|
+
# Find the video page data
|
|
423
|
+
for key, val in loader.items():
|
|
424
|
+
if isinstance(val, dict):
|
|
425
|
+
video_res = val.get("videoInfoRes", {})
|
|
426
|
+
if isinstance(video_res, dict):
|
|
427
|
+
items = video_res.get("item_list", [])
|
|
428
|
+
if items:
|
|
429
|
+
return items[0]
|
|
430
|
+
|
|
431
|
+
# item_list empty = overseas IP blocked
|
|
432
|
+
raise DouyinAPIError(
|
|
433
|
+
f"视频数据为空 (可能需要国内 IP/代理): {aweme_id}\n"
|
|
434
|
+
" 提示: dy config set api.proxy http://your-proxy:port"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
except httpx.RequestError as e:
|
|
438
|
+
raise DouyinAPIError(f"请求失败: {e}") from e
|
|
439
|
+
except json.JSONDecodeError as e:
|
|
440
|
+
raise DouyinAPIError(f"JSON 解析失败: {e}") from e
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# Comments
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def get_comments(
|
|
447
|
+
self,
|
|
448
|
+
aweme_id: str,
|
|
449
|
+
cursor: int = 0,
|
|
450
|
+
count: int = 20,
|
|
451
|
+
) -> dict:
|
|
452
|
+
"""获取视频评论列表。"""
|
|
453
|
+
params = {
|
|
454
|
+
**get_base_params(),
|
|
455
|
+
"aweme_id": aweme_id,
|
|
456
|
+
"cursor": str(cursor),
|
|
457
|
+
"count": str(count),
|
|
458
|
+
"item_type": "0",
|
|
459
|
+
}
|
|
460
|
+
data = self._get(VIDEO_COMMENTS_URL, params=params)
|
|
461
|
+
|
|
462
|
+
if data.get("status_code") != 0:
|
|
463
|
+
raise DouyinAPIError(
|
|
464
|
+
f"获取评论失败: {data.get('status_msg', 'unknown error')}"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return data
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# User
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def get_user_profile(self, sec_user_id: str) -> dict:
|
|
474
|
+
"""获取用户资料。"""
|
|
475
|
+
params = {
|
|
476
|
+
**get_base_params(),
|
|
477
|
+
"sec_user_id": sec_user_id,
|
|
478
|
+
}
|
|
479
|
+
data = self._get(USER_PROFILE_URL, params=params)
|
|
480
|
+
|
|
481
|
+
if data.get("status_code") != 0:
|
|
482
|
+
raise DouyinAPIError(
|
|
483
|
+
f"获取用户资料失败: {data.get('status_msg', 'unknown error')}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return data.get("user", data)
|
|
487
|
+
|
|
488
|
+
def get_user_posts(
|
|
489
|
+
self,
|
|
490
|
+
sec_user_id: str,
|
|
491
|
+
max_cursor: int = 0,
|
|
492
|
+
count: int = 20,
|
|
493
|
+
) -> dict:
|
|
494
|
+
"""获取用户作品列表。"""
|
|
495
|
+
params = {
|
|
496
|
+
**get_base_params(),
|
|
497
|
+
"sec_user_id": sec_user_id,
|
|
498
|
+
"max_cursor": str(max_cursor),
|
|
499
|
+
"count": str(count),
|
|
500
|
+
}
|
|
501
|
+
data = self._get(USER_POSTS_URL, params=params)
|
|
502
|
+
|
|
503
|
+
if data.get("status_code") != 0:
|
|
504
|
+
raise DouyinAPIError(
|
|
505
|
+
f"获取用户作品失败: {data.get('status_msg', 'unknown error')}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return data
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Trending
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def get_trending(self) -> list[dict]:
|
|
515
|
+
"""获取抖音热榜。"""
|
|
516
|
+
params = get_base_params()
|
|
517
|
+
data = self._get(TRENDING_URL, params=params)
|
|
518
|
+
|
|
519
|
+
if data.get("status_code") != 0:
|
|
520
|
+
raise DouyinAPIError(
|
|
521
|
+
f"获取热榜失败: {data.get('status_msg', 'unknown error')}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
word_list = data.get("data", {}).get("word_list", [])
|
|
525
|
+
return word_list
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
# Download
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def get_download_url(self, aweme_id: str) -> dict[str, Any]:
|
|
532
|
+
"""
|
|
533
|
+
获取无水印下载链接。
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
{
|
|
537
|
+
"video_url": str | None,
|
|
538
|
+
"music_url": str | None,
|
|
539
|
+
"images": list[str] | None,
|
|
540
|
+
"desc": str,
|
|
541
|
+
"author": str,
|
|
542
|
+
}
|
|
543
|
+
"""
|
|
544
|
+
detail = self.get_video_detail(aweme_id)
|
|
545
|
+
|
|
546
|
+
result: dict[str, Any] = {
|
|
547
|
+
"video_url": None,
|
|
548
|
+
"music_url": None,
|
|
549
|
+
"images": None,
|
|
550
|
+
"desc": detail.get("desc", ""),
|
|
551
|
+
"author": detail.get("author", {}).get("nickname", ""),
|
|
552
|
+
"aweme_id": aweme_id,
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
# Video
|
|
556
|
+
video = detail.get("video", {})
|
|
557
|
+
play_addr = video.get("play_addr", {})
|
|
558
|
+
url_list = play_addr.get("url_list", [])
|
|
559
|
+
if url_list:
|
|
560
|
+
# 取最后一个(通常是最高质量)
|
|
561
|
+
result["video_url"] = url_list[-1].replace("playwm", "play")
|
|
562
|
+
|
|
563
|
+
# Images (for image posts)
|
|
564
|
+
images = detail.get("images", [])
|
|
565
|
+
if images:
|
|
566
|
+
image_urls = []
|
|
567
|
+
for img in images:
|
|
568
|
+
url_list = img.get("url_list", [])
|
|
569
|
+
if url_list:
|
|
570
|
+
image_urls.append(url_list[-1])
|
|
571
|
+
result["images"] = image_urls
|
|
572
|
+
|
|
573
|
+
# Music
|
|
574
|
+
music = detail.get("music", {})
|
|
575
|
+
music_play = music.get("play_url", {})
|
|
576
|
+
if isinstance(music_play, dict):
|
|
577
|
+
music_urls = music_play.get("url_list", [])
|
|
578
|
+
if music_urls:
|
|
579
|
+
result["music_url"] = music_urls[0]
|
|
580
|
+
elif isinstance(music_play, str):
|
|
581
|
+
result["music_url"] = music_play
|
|
582
|
+
|
|
583
|
+
return result
|
|
584
|
+
|
|
585
|
+
def download_file(
|
|
586
|
+
self,
|
|
587
|
+
url: str,
|
|
588
|
+
output_path: str,
|
|
589
|
+
progress_callback: Any = None,
|
|
590
|
+
) -> str:
|
|
591
|
+
"""
|
|
592
|
+
下载文件到本地。
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
url: 下载链接
|
|
596
|
+
output_path: 保存路径
|
|
597
|
+
progress_callback: 进度回调 (downloaded, total)
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
保存的文件路径
|
|
601
|
+
"""
|
|
602
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
603
|
+
headers = get_headers()
|
|
604
|
+
|
|
605
|
+
with self.client.stream("GET", url, headers=headers) as resp:
|
|
606
|
+
resp.raise_for_status()
|
|
607
|
+
total = int(resp.headers.get("Content-Length", 0))
|
|
608
|
+
downloaded = 0
|
|
609
|
+
|
|
610
|
+
with open(output_path, "wb") as f:
|
|
611
|
+
for chunk in resp.iter_bytes(chunk_size=8192):
|
|
612
|
+
f.write(chunk)
|
|
613
|
+
downloaded += len(chunk)
|
|
614
|
+
if progress_callback:
|
|
615
|
+
progress_callback(downloaded, total)
|
|
616
|
+
|
|
617
|
+
return output_path
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# Live
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
def get_live_info(self, web_rid: str) -> dict:
|
|
624
|
+
"""
|
|
625
|
+
获取直播间信息。
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
web_rid: 直播间 ID (URL 中的数字, 如 live.douyin.com/123456789)
|
|
629
|
+
"""
|
|
630
|
+
params = {
|
|
631
|
+
**get_base_params(),
|
|
632
|
+
"aid": "6383",
|
|
633
|
+
"app_name": "douyin_web",
|
|
634
|
+
"live_id": "1",
|
|
635
|
+
"device_platform": "web",
|
|
636
|
+
"enter_from": "web_live",
|
|
637
|
+
"web_rid": web_rid,
|
|
638
|
+
"room_id_str": "",
|
|
639
|
+
"enter_source": "",
|
|
640
|
+
}
|
|
641
|
+
data = self._get(LIVE_INFO_URL, params=params)
|
|
642
|
+
|
|
643
|
+
if data.get("status_code") != 0:
|
|
644
|
+
raise DouyinAPIError(
|
|
645
|
+
f"获取直播信息失败: {data.get('status_msg', 'unknown error')}"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
room_data = data.get("data", {})
|
|
649
|
+
rooms = room_data.get("data", []) if isinstance(room_data.get("data"), list) else []
|
|
650
|
+
if rooms:
|
|
651
|
+
return rooms[0]
|
|
652
|
+
return room_data
|
|
653
|
+
|
|
654
|
+
# ------------------------------------------------------------------
|
|
655
|
+
# Feed
|
|
656
|
+
# ------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
def get_feed(self, count: int = 10) -> list[dict]:
|
|
659
|
+
"""获取推荐 Feed。"""
|
|
660
|
+
params = {
|
|
661
|
+
**get_base_params(),
|
|
662
|
+
"count": str(count),
|
|
663
|
+
}
|
|
664
|
+
data = self._get(FEED_URL, params=params)
|
|
665
|
+
return data.get("aweme_list", [])
|