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.
@@ -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", [])