返回 douyin-downloader
test_user_downloader.py
根目录 / tests / test_user_downloader.py
1 import asyncio
2 from typing import Any, Dict, List
3
4 from control.queue_manager import QueueManager
5 from core.user_downloader import UserDownloader
6 from storage.file_manager import FileManager
7
8
9 def _make_aweme(aweme_id: str, **overrides: Any) -> Dict[str, Any]:
10 aweme = {
11 "aweme_id": aweme_id,
12 "desc": f"desc-{aweme_id}",
13 "create_time": 1700000000,
14 "author": {"nickname": "tester", "uid": "uid-1"},
15 "video": {"play_addr": {"url_list": ["https://example.com/video.mp4"]}},
16 }
17 aweme.update(overrides)
18 return aweme
19
20
21 class _FakeConfig:
22 def __init__(self, data: Dict[str, Any]):
23 self._data = data
24
25 def get(self, key: str, default: Any = None) -> Any:
26 return self._data.get(key, default)
27
28
29 class _FakeCookieManager:
30 pass
31
32
33 class _NoopRateLimiter:
34 async def acquire(self):
35 return
36
37
38 class _FakeProgressReporter:
39 def __init__(self):
40 self.step_updates: List[tuple[str, str]] = []
41 self.item_totals: List[tuple[int, str]] = []
42 self.item_events: List[tuple[str, str]] = []
43
44 def update_step(self, step: str, detail: str = "") -> None:
45 self.step_updates.append((step, detail))
46
47 def set_item_total(self, total: int, detail: str = "") -> None:
48 self.item_totals.append((total, detail))
49
50 def advance_item(self, status: str, detail: str = "") -> None:
51 self.item_events.append((status, detail))
52
53
54 class _FakeAPIClient:
55 def __init__(self):
56 self.user_post_calls: List[int] = []
57 self.browser_calls = 0
58 self.detail_calls: List[str] = []
59 self.detail_call_kwargs: List[Dict[str, Any]] = []
60 self.browser_call_kwargs: List[Dict[str, Any]] = []
61 self.browser_post_items: Dict[str, Dict[str, Any]] = {}
62 self.browser_post_stats: Dict[str, int] = {}
63
64 async def get_user_post(self, _sec_uid: str, max_cursor: int = 0, _count: int = 20):
65 self.user_post_calls.append(max_cursor)
66 if max_cursor == 0:
67 return {
68 "status_code": 0,
69 "aweme_list": [_make_aweme("111")],
70 "has_more": 1,
71 "max_cursor": 123,
72 "not_login_module": {"guide_login_tip_exist": True},
73 }
74 return {"status_code": 0}
75
76 async def collect_user_post_ids_via_browser(self, *_args, **_kwargs):
77 self.browser_calls += 1
78 self.browser_call_kwargs.append(dict(_kwargs))
79 return ["111", "222", "333"]
80
81 async def get_video_detail(self, aweme_id: str, **kwargs):
82 self.detail_calls.append(aweme_id)
83 self.detail_call_kwargs.append(kwargs)
84 return _make_aweme(aweme_id)
85
86 def pop_browser_post_aweme_items(self):
87 data = self.browser_post_items
88 self.browser_post_items = {}
89 return data
90
91 def pop_browser_post_stats(self):
92 data = self.browser_post_stats
93 self.browser_post_stats = {}
94 return data
95
96
97 def _build_downloader(
98 tmp_path,
99 api_client,
100 browser_enabled: bool,
101 progress_reporter=None,
102 number_post: int = 0,
103 ) -> UserDownloader:
104 config_data = {
105 "number": {"post": number_post},
106 "increase": {"post": False},
107 "mode": ["post"],
108 "thread": 2,
109 "browser_fallback": {
110 "enabled": browser_enabled,
111 "headless": True,
112 "max_scrolls": 10,
113 "idle_rounds": 2,
114 "wait_timeout_seconds": 5,
115 },
116 }
117 config = _FakeConfig(config_data)
118 file_manager = FileManager(str(tmp_path / "Downloaded"))
119 downloader = UserDownloader(
120 config=config,
121 api_client=api_client,
122 file_manager=file_manager,
123 cookie_manager=_FakeCookieManager(),
124 database=None,
125 rate_limiter=_NoopRateLimiter(),
126 retry_handler=None,
127 queue_manager=QueueManager(max_workers=2),
128 )
129 downloader.progress_reporter = progress_reporter
130 return downloader
131
132
133 def test_user_post_browser_fallback_recovers_missing_pages(tmp_path, monkeypatch):
134 api_client = _FakeAPIClient()
135 downloader = _build_downloader(tmp_path, api_client, browser_enabled=True)
136
137 async def _always_true(*_args, **_kwargs):
138 return True
139
140 monkeypatch.setattr(downloader, "_should_download", _always_true)
141 monkeypatch.setattr(downloader, "_download_aweme_assets", _always_true)
142
143 result = asyncio.run(
144 downloader._download_user_post(
145 "sec_uid_x",
146 {"uid": "uid-1", "nickname": "tester", "aweme_count": 3},
147 )
148 )
149
150 assert result.total == 3
151 assert result.success == 3
152 assert api_client.browser_calls == 1
153 assert api_client.browser_call_kwargs[0].get("expected_count") == 0
154 assert api_client.detail_calls == ["222", "333"]
155 assert all(call.get("suppress_error") is True for call in api_client.detail_call_kwargs)
156
157
158 def test_user_post_browser_fallback_can_be_disabled(tmp_path, monkeypatch):
159 api_client = _FakeAPIClient()
160 downloader = _build_downloader(tmp_path, api_client, browser_enabled=False)
161
162 async def _always_true(*_args, **_kwargs):
163 return True
164
165 monkeypatch.setattr(downloader, "_should_download", _always_true)
166 monkeypatch.setattr(downloader, "_download_aweme_assets", _always_true)
167
168 result = asyncio.run(
169 downloader._download_user_post(
170 "sec_uid_x",
171 {"uid": "uid-1", "nickname": "tester", "aweme_count": 3},
172 )
173 )
174
175 assert result.total == 1
176 assert result.success == 1
177 assert api_client.browser_calls == 0
178 assert api_client.detail_calls == []
179 assert api_client.detail_call_kwargs == []
180
181
182 def test_user_post_browser_fallback_prefers_browser_aweme_items(tmp_path, monkeypatch):
183 api_client = _FakeAPIClient()
184 api_client.browser_post_items = {
185 "222": _make_aweme("222"),
186 "333": _make_aweme("333"),
187 }
188 downloader = _build_downloader(tmp_path, api_client, browser_enabled=True)
189
190 async def _always_true(*_args, **_kwargs):
191 return True
192
193 monkeypatch.setattr(downloader, "_should_download", _always_true)
194 monkeypatch.setattr(downloader, "_download_aweme_assets", _always_true)
195
196 result = asyncio.run(
197 downloader._download_user_post(
198 "sec_uid_x",
199 {"uid": "uid-1", "nickname": "tester", "aweme_count": 3},
200 )
201 )
202
203 assert result.total == 3
204 assert result.success == 3
205 assert api_client.detail_calls == []
206
207
208 def test_user_post_browser_fallback_expected_count_uses_number_limit(tmp_path, monkeypatch):
209 api_client = _FakeAPIClient()
210 downloader = _build_downloader(
211 tmp_path,
212 api_client,
213 browser_enabled=True,
214 number_post=2,
215 )
216
217 async def _always_true(*_args, **_kwargs):
218 return True
219
220 monkeypatch.setattr(downloader, "_should_download", _always_true)
221 monkeypatch.setattr(downloader, "_download_aweme_assets", _always_true)
222
223 result = asyncio.run(
224 downloader._download_user_post(
225 "sec_uid_x",
226 {"uid": "uid-1", "nickname": "tester", "aweme_count": 999},
227 )
228 )
229
230 assert result.total == 2
231 assert api_client.browser_calls == 1
232 assert api_client.browser_call_kwargs[0].get("expected_count") == 2
233
234
235 def test_user_post_skips_pinned_before_number_limit(tmp_path, monkeypatch):
236 class _PinnedAPIClient(_FakeAPIClient):
237 async def get_user_post(self, _sec_uid: str, max_cursor: int = 0, _count: int = 20):
238 self.user_post_calls.append(max_cursor)
239 if max_cursor == 0:
240 return {
241 "status_code": 0,
242 "aweme_list": [
243 _make_aweme("111", is_top=1),
244 _make_aweme("222", is_top=1),
245 _make_aweme("333", is_top=0),
246 ],
247 "has_more": 1,
248 "max_cursor": 456,
249 }
250 return {
251 "status_code": 0,
252 "aweme_list": [_make_aweme("444", is_top=0)],
253 "has_more": 0,
254 "max_cursor": max_cursor,
255 }
256
257 api_client = _PinnedAPIClient()
258 downloader = _build_downloader(
259 tmp_path,
260 api_client,
261 browser_enabled=False,
262 number_post=2,
263 )
264
265 downloaded_ids: List[str] = []
266
267 async def _always_true(*_args, **_kwargs):
268 return True
269
270 async def _download_aweme_assets(item, *_args, **_kwargs):
271 downloaded_ids.append(str(item.get("aweme_id")))
272 return True
273
274 monkeypatch.setattr(downloader, "_should_download", _always_true)
275 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_aweme_assets)
276
277 result = asyncio.run(
278 downloader._download_user_post(
279 "sec_uid_x",
280 {"uid": "uid-1", "nickname": "tester", "aweme_count": 4},
281 )
282 )
283
284 assert result.total == 2
285 assert result.success == 2
286 assert downloaded_ids == ["333", "444"]
287
288
289 def test_user_post_reports_step_and_item_progress(tmp_path, monkeypatch):
290 api_client = _FakeAPIClient()
291 reporter = _FakeProgressReporter()
292 downloader = _build_downloader(
293 tmp_path,
294 api_client,
295 browser_enabled=True,
296 progress_reporter=reporter,
297 )
298
299 async def _fake_should_download(aweme_id):
300 return aweme_id != "222"
301
302 async def _fake_download_aweme_assets(item, *_args, **_kwargs):
303 return item.get("aweme_id") != "333"
304
305 monkeypatch.setattr(downloader, "_should_download", _fake_should_download)
306 monkeypatch.setattr(downloader, "_download_aweme_assets", _fake_download_aweme_assets)
307
308 result = asyncio.run(
309 downloader._download_user_post(
310 "sec_uid_x",
311 {"uid": "uid-1", "nickname": "tester", "aweme_count": 3},
312 )
313 )
314
315 assert result.total == 3
316 assert result.success == 1
317 assert result.skipped == 1
318 assert result.failed == 1
319 assert reporter.item_totals == [(3, "作品待下载")]
320 assert ("下载作品", "待处理 3 条") in reporter.step_updates
321 statuses = [status for status, _detail in reporter.item_events]
322 assert statuses.count("success") == 1
323 assert statuses.count("skipped") == 1
324 assert statuses.count("failed") == 1
325
325 lines PYTHON