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