返回 douyin-downloader
test_api_client.py
根目录 / tests / test_api_client.py
1 import asyncio
2 import sys
3 import types
4
5 import pytest
6
7 from core.api_client import DouyinAPIClient
8
9
10 def test_default_query_uses_existing_ms_token():
11 client = DouyinAPIClient({"msToken": "token-1"})
12 params = asyncio.run(client._default_query())
13 assert params["msToken"] == "token-1"
14
15
16 def test_build_signed_path_fallbacks_to_xbogus_when_abogus_disabled():
17 client = DouyinAPIClient({"msToken": "token-1"})
18 client._abogus_enabled = False
19 signed_url, _ua = client.build_signed_path("/aweme/v1/web/aweme/detail/", {"a": 1})
20 assert "X-Bogus=" in signed_url
21
22
23 def test_build_signed_path_prefers_abogus(monkeypatch):
24 class _FakeFp:
25 @staticmethod
26 def generate_fingerprint(_browser):
27 return "fp"
28
29 class _FakeABogus:
30 def __init__(self, fp, user_agent):
31 self.fp = fp
32 self.user_agent = user_agent
33
34 def generate_abogus(self, params, body=""):
35 return (f"{params}&a_bogus=fake_ab", "fake_ab", self.user_agent, body)
36
37 import core.api_client as api_module
38
39 monkeypatch.setattr(api_module, "BrowserFingerprintGenerator", _FakeFp)
40 monkeypatch.setattr(api_module, "ABogus", _FakeABogus)
41
42 client = DouyinAPIClient({"msToken": "token-1"})
43 client._abogus_enabled = True
44
45 signed_url, _ua = client.build_signed_path("/aweme/v1/web/aweme/detail/", {"a": 1})
46 assert "a_bogus=fake_ab" in signed_url
47
48
49 def test_browser_fallback_caps_warmup_wait(monkeypatch):
50 class _FakeMouse:
51 async def wheel(self, _x, _y):
52 return
53
54 class _FakePage:
55 def __init__(self):
56 self.mouse = _FakeMouse()
57 self.wait_calls = 0
58 self._response_handler = None
59
60 def on(self, event_name, callback):
61 if event_name == "response":
62 self._response_handler = callback
63
64 async def goto(self, *_args, **_kwargs):
65 return
66
67 async def title(self):
68 return "抖音"
69
70 def is_closed(self):
71 return False
72
73 async def wait_for_timeout(self, _ms):
74 self.wait_calls += 1
75
76 class _FakeContext:
77 def __init__(self, page):
78 self._page = page
79
80 async def add_cookies(self, _cookies):
81 return
82
83 async def new_page(self):
84 return self._page
85
86 async def cookies(self, _base_url):
87 return []
88
89 async def close(self):
90 return
91
92 class _FakeBrowser:
93 def __init__(self, context):
94 self._context = context
95
96 async def new_context(self, **_kwargs):
97 return self._context
98
99 async def close(self):
100 return
101
102 class _FakeChromium:
103 def __init__(self, browser):
104 self._browser = browser
105
106 async def launch(self, **_kwargs):
107 return self._browser
108
109 class _FakePlaywright:
110 def __init__(self, chromium):
111 self.chromium = chromium
112
113 class _FakePlaywrightManager:
114 def __init__(self, playwright):
115 self._playwright = playwright
116
117 async def __aenter__(self):
118 return self._playwright
119
120 async def __aexit__(self, *_args):
121 return
122
123 page = _FakePage()
124 context = _FakeContext(page)
125 browser = _FakeBrowser(context)
126 chromium = _FakeChromium(browser)
127 playwright = _FakePlaywright(chromium)
128 manager = _FakePlaywrightManager(playwright)
129
130 fake_playwright_pkg = types.ModuleType("playwright")
131 fake_async_api = types.ModuleType("playwright.async_api")
132 fake_async_api.async_playwright = lambda: manager
133 monkeypatch.setitem(sys.modules, "playwright", fake_playwright_pkg)
134 monkeypatch.setitem(sys.modules, "playwright.async_api", fake_async_api)
135
136 client = DouyinAPIClient({"msToken": "token-1"})
137
138 async def _fake_extract(_page):
139 return []
140
141 monkeypatch.setattr(client, "_extract_aweme_ids_from_page", _fake_extract)
142
143 ids = asyncio.run(
144 client.collect_user_post_ids_via_browser(
145 "sec_uid_x",
146 expected_count=0,
147 headless=False,
148 max_scrolls=240,
149 idle_rounds=3,
150 wait_timeout_seconds=600,
151 )
152 )
153
154 assert ids == []
155 # warmup should be capped instead of waiting full wait_timeout_seconds
156 # and scrolling should stop after idle rounds even when no id is found
157 assert page.wait_calls <= 30
158 stats = client.pop_browser_post_stats()
159 assert stats["selected_ids"] == 0
160 assert client.pop_browser_post_stats() == {}
161
162
163 @pytest.mark.asyncio
164 async def test_get_user_post_returns_normalized_dto(monkeypatch):
165 client = DouyinAPIClient({"msToken": "token-1"})
166 captured_params = {}
167
168 async def _fake_request_json(path, params, suppress_error=False):
169 assert path == "/aweme/v1/web/aweme/post/"
170 captured_params.update(params)
171 return {
172 "status_code": 0,
173 "aweme_list": [{"aweme_id": "111"}],
174 "has_more": 1,
175 "max_cursor": 9,
176 }
177
178 monkeypatch.setattr(client, "_request_json", _fake_request_json)
179 data = await client.get_user_post("sec-1", max_cursor=0, count=20)
180
181 assert data["items"] == [{"aweme_id": "111"}]
182 assert data["aweme_list"] == [{"aweme_id": "111"}]
183 assert data["has_more"] is True
184 assert data["max_cursor"] == 9
185 assert data["status_code"] == 0
186 assert data["source"] == "api"
187 assert isinstance(data["raw"], dict)
188 assert captured_params["show_live_replay_strategy"] == "1"
189 assert captured_params["need_time_list"] == "1"
190 assert captured_params["time_list_query"] == "0"
191
192
193 @pytest.mark.asyncio
194 async def test_user_mode_endpoints_use_shared_paged_normalization(monkeypatch):
195 client = DouyinAPIClient({"msToken": "token-1"})
196 called_requests = []
197
198 async def _fake_request_json(path, params, suppress_error=False):
199 called_requests.append((path, dict(params)))
200 return {"status_code": 0, "aweme_list": [], "has_more": 0, "max_cursor": 0}
201
202 monkeypatch.setattr(client, "_request_json", _fake_request_json)
203
204 like_data = await client.get_user_like("sec-1", max_cursor=0, count=20)
205 mix_data = await client.get_user_mix("sec-1", max_cursor=0, count=20)
206 music_data = await client.get_user_music("sec-1", max_cursor=0, count=20)
207
208 assert [path for path, _params in called_requests] == [
209 "/aweme/v1/web/aweme/favorite/",
210 "/aweme/v1/web/mix/list/",
211 "/aweme/v1/web/music/list/",
212 ]
213 mix_params = called_requests[1][1]
214 music_params = called_requests[2][1]
215 for forbidden_key in (
216 "show_live_replay_strategy",
217 "need_time_list",
218 "time_list_query",
219 ):
220 assert forbidden_key not in mix_params
221 assert forbidden_key not in music_params
222 assert like_data["items"] == []
223 assert mix_data["items"] == []
224 assert music_data["items"] == []
225
226
227 @pytest.mark.asyncio
228 async def test_collect_endpoints_use_expected_paths_and_normalization(monkeypatch):
229 client = DouyinAPIClient({"msToken": "token-1"})
230 called_requests = []
231
232 async def _fake_request_json(path, params, suppress_error=False):
233 called_requests.append((path, dict(params)))
234 if path == "/aweme/v1/web/collects/list/":
235 return {
236 "status_code": 0,
237 "collects_list": [{"collects_id_str": "collect-1"}],
238 "has_more": 1,
239 "cursor": 9,
240 }
241 if path == "/aweme/v1/web/collects/video/list/":
242 return {
243 "status_code": 0,
244 "aweme_list": [{"aweme_id": "aweme-1"}],
245 "has_more": 0,
246 "cursor": 0,
247 }
248 if path == "/aweme/v1/web/mix/listcollection/":
249 return {
250 "status_code": 0,
251 "mix_infos": [{"mix_id": "mix-1"}],
252 "has_more": 0,
253 "cursor": 0,
254 }
255 return {"status_code": 0, "has_more": 0, "cursor": 0}
256
257 monkeypatch.setattr(client, "_request_json", _fake_request_json)
258
259 collects_data = await client.get_user_collects("self", max_cursor=0, count=10)
260 collect_aweme_data = await client.get_collect_aweme("collect-1", max_cursor=0, count=10)
261 collect_mix_data = await client.get_user_collect_mix("self", max_cursor=0, count=12)
262
263 assert [path for path, _params in called_requests] == [
264 "/aweme/v1/web/collects/list/",
265 "/aweme/v1/web/collects/video/list/",
266 "/aweme/v1/web/mix/listcollection/",
267 ]
268 assert called_requests[0][1]["count"] == 10
269 assert called_requests[0][1]["version_code"] == "170400"
270 assert called_requests[1][1]["collects_id"] == "collect-1"
271 assert called_requests[1][1]["count"] == 10
272 assert called_requests[2][1]["count"] == 12
273 assert collects_data["items"] == [{"collects_id_str": "collect-1"}]
274 assert collects_data["has_more"] is True
275 assert collects_data["max_cursor"] == 9
276 assert collect_aweme_data["items"] == [{"aweme_id": "aweme-1"}]
277 assert collect_mix_data["items"] == [{"mix_id": "mix-1"}]
278
279
280 @pytest.mark.asyncio
281 async def test_mix_and_music_endpoints_are_normalized(monkeypatch):
282 client = DouyinAPIClient({"msToken": "token-1"})
283
284 async def _fake_request_json(path, _params, suppress_error=False):
285 if path == "/aweme/v1/web/mix/detail/":
286 return {"mix_info": {"mix_id": "mix-1"}}
287 if path == "/aweme/v1/web/mix/aweme/":
288 return {"status_code": 0, "aweme_list": [{"aweme_id": "a-1"}], "has_more": 0}
289 if path == "/aweme/v1/web/music/detail/":
290 return {"music_info": {"id": "music-1"}}
291 if path == "/aweme/v1/web/music/aweme/":
292 return {"status_code": 0, "aweme_list": [{"aweme_id": "a-2"}], "has_more": 0}
293 raise AssertionError(f"unexpected path: {path}")
294
295 monkeypatch.setattr(client, "_request_json", _fake_request_json)
296
297 mix_detail = await client.get_mix_detail("mix-1")
298 mix_page = await client.get_mix_aweme("mix-1", cursor=0, count=20)
299 music_detail = await client.get_music_detail("music-1")
300 music_page = await client.get_music_aweme("music-1", cursor=0, count=20)
301
302 assert mix_detail == {"mix_id": "mix-1"}
303 assert music_detail == {"id": "music-1"}
304 assert mix_page["items"] == [{"aweme_id": "a-1"}]
305 assert music_page["items"] == [{"aweme_id": "a-2"}]
306
307
308 class _FakeRedirectResp:
309 def __init__(self, status: int, final_url: str):
310 self.status = status
311 self.url = final_url
312
313 async def __aenter__(self):
314 return self
315
316 async def __aexit__(self, *args):
317 return False
318
319
320 class _FakeSession:
321 def __init__(self, status: int, final_url: str):
322 self._status = status
323 self._final_url = final_url
324 self.closed = False
325
326 def get(self, url, allow_redirects=True, timeout=None, proxy=None):
327 return _FakeRedirectResp(self._status, self._final_url)
328
329 async def close(self):
330 self.closed = True
331
332
333 @pytest.mark.asyncio
334 async def test_resolve_short_url_returns_final_url_on_200():
335 client = DouyinAPIClient({"msToken": "t"})
336 client._session = _FakeSession(200, "https://www.douyin.com/video/123")
337 resolved = await client.resolve_short_url("https://v.douyin.com/abc")
338 assert resolved == "https://www.douyin.com/video/123"
339 await client.close()
340
341
342 @pytest.mark.asyncio
343 async def test_resolve_short_url_returns_none_on_404():
344 """HTTP 4xx 不应把错误 URL 继续传给 parser。"""
345 client = DouyinAPIClient({"msToken": "t"})
346 client._session = _FakeSession(404, "https://www.douyin.com/error")
347 resolved = await client.resolve_short_url("https://v.douyin.com/deadbeef")
348 assert resolved is None
349 await client.close()
350
351
352 @pytest.mark.asyncio
353 async def test_resolve_short_url_returns_none_on_500():
354 client = DouyinAPIClient({"msToken": "t"})
355 client._session = _FakeSession(502, "https://www.douyin.com/error")
356 resolved = await client.resolve_short_url("https://v.douyin.com/xyz")
357 assert resolved is None
358 await client.close()
359
360
361 @pytest.mark.asyncio
362 async def test_get_video_detail_retries_with_different_aid_on_filter():
363 """When the first aid candidate returns filter_reason, get_video_detail
364 should retry with the next candidate and return the detail."""
365 client = DouyinAPIClient({"msToken": "t"})
366 call_count = 0
367
368 async def _fake_request_json(path, params, **kwargs):
369 nonlocal call_count
370 call_count += 1
371 aid = params.get("aid")
372 if aid == client._DETAIL_AID_CANDIDATES[0]:
373 # Simulate filter on the first candidate
374 return {
375 "aweme_detail": None,
376 "filter_detail": {
377 "filter_reason": "images_base",
378 "aweme_id": "123",
379 },
380 "status_code": 0,
381 }
382 # Second candidate returns the detail successfully
383 return {
384 "aweme_detail": {
385 "aweme_id": "123",
386 "aweme_type": 68,
387 "images": [{"url_list": ["https://example.com/img.webp"]}],
388 },
389 "status_code": 0,
390 }
391
392 client._request_json = _fake_request_json
393
394 detail = await client.get_video_detail("123")
395
396 assert detail is not None
397 assert detail["aweme_id"] == "123"
398 assert detail["aweme_type"] == 68
399 assert call_count == 2 # first call filtered, second succeeded
400
401
402 @pytest.mark.asyncio
403 async def test_get_video_detail_returns_on_first_success():
404 """When the first aid candidate returns valid detail, no retry happens."""
405 client = DouyinAPIClient({"msToken": "t"})
406 call_count = 0
407
408 async def _fake_request_json(path, params, **kwargs):
409 nonlocal call_count
410 call_count += 1
411 return {
412 "aweme_detail": {"aweme_id": "456", "aweme_type": 4},
413 "status_code": 0,
414 }
415
416 client._request_json = _fake_request_json
417
418 detail = await client.get_video_detail("456")
419
420 assert detail is not None
421 assert detail["aweme_id"] == "456"
422 assert call_count == 1 # no retry needed
423
423 lines PYTHON