返回 douyin-downloader
downloader_base.py
根目录 / core / downloader_base.py
1 import json
2 import re
3 from abc import ABC, abstractmethod
4 from datetime import datetime
5 from pathlib import Path
6 from typing import Any, Dict, List, Optional, Protocol, Tuple
7 from urllib.parse import urlparse
8
9 from auth import CookieManager
10 from config import ConfigLoader
11 from control import QueueManager, RateLimiter, RetryHandler
12 from core.api_client import DouyinAPIClient
13 from core.metadata import extract_author_sec_uid
14 from core.transcript_manager import TranscriptManager
15 from storage import Database, FileManager, MetadataHandler
16 from utils.logger import setup_logger
17 from utils.naming import (
18 DEFAULT_FILE_TEMPLATE,
19 DEFAULT_FOLDER_TEMPLATE,
20 build_aweme_context,
21 render_template,
22 )
23
24 logger = setup_logger("BaseDownloader")
25
26
27 class ProgressReporter(Protocol):
28 def update_step(self, step: str, detail: str = "") -> None: ...
29
30 def set_item_total(self, total: int, detail: str = "") -> None: ...
31
32 def advance_item(self, status: str, detail: str = "") -> None: ...
33
34
35 class DownloadResult:
36 def __init__(self):
37 self.total = 0
38 self.success = 0
39 self.failed = 0
40 self.skipped = 0
41
42 def __str__(self):
43 return f"Total: {self.total}, Success: {self.success}, Failed: {self.failed}, Skipped: {self.skipped}"
44
45
46 class BaseDownloader(ABC):
47 def __init__(
48 self,
49 config: ConfigLoader,
50 api_client: DouyinAPIClient,
51 file_manager: FileManager,
52 cookie_manager: CookieManager,
53 database: Optional[Database] = None,
54 rate_limiter: Optional[RateLimiter] = None,
55 retry_handler: Optional[RetryHandler] = None,
56 queue_manager: Optional[QueueManager] = None,
57 progress_reporter: Optional[ProgressReporter] = None,
58 ):
59 self.config = config
60 self.api_client = api_client
61 self.file_manager = file_manager
62 self.cookie_manager = cookie_manager
63 self.database = database
64 self.rate_limiter = rate_limiter or RateLimiter()
65 self.retry_handler = retry_handler or RetryHandler()
66 thread_count = int(self.config.get("thread", 5) or 5)
67 self.queue_manager = queue_manager or QueueManager(max_workers=thread_count)
68 self.progress_reporter = progress_reporter
69 self.metadata_handler = MetadataHandler()
70 self.transcript_manager = TranscriptManager(self.config, self.file_manager, self.database)
71 self._local_aweme_ids: Optional[set[str]] = None
72 self._aweme_id_pattern = re.compile(r"(?<!\d)(\d{15,20})(?!\d)")
73 self._local_media_suffixes = {
74 ".mp4",
75 ".jpg",
76 ".jpeg",
77 ".png",
78 ".webp",
79 ".gif",
80 ".mp3",
81 ".m4a",
82 }
83 # 控制终端错误日志量,避免进度条被大量日志打断后出现重复重绘。
84 self._download_error_log_count = 0
85 self._download_error_log_limit = 5
86
87 def _progress_update_step(self, step: str, detail: str = "") -> None:
88 if not self.progress_reporter:
89 return
90 try:
91 self.progress_reporter.update_step(step, detail)
92 except Exception as exc:
93 logger.debug("Progress update_step failed: %s", exc)
94
95 def _progress_set_item_total(self, total: int, detail: str = "") -> None:
96 if not self.progress_reporter:
97 return
98 try:
99 self.progress_reporter.set_item_total(total, detail)
100 except Exception as exc:
101 logger.debug("Progress set_item_total failed: %s", exc)
102
103 def _progress_advance_item(self, status: str, detail: str = "") -> None:
104 if not self.progress_reporter:
105 return
106 try:
107 self.progress_reporter.advance_item(status, detail)
108 except Exception as exc:
109 logger.debug("Progress advance_item failed: %s", exc)
110
111 def _progress_report_author(
112 self,
113 nickname: Optional[str] = None,
114 sec_uid: Optional[str] = None,
115 ) -> None:
116 """Surface author metadata to the reporter so the hosting job can
117 cache it for retry and display.
118
119 Downloaders call this as soon as author info is known (user_info
120 lookup for batch jobs, aweme_data.author for single-video jobs).
121 Safe to call with `None` values — the reporter drops empty payloads.
122 """
123 if not self.progress_reporter:
124 return
125 try:
126 fn = getattr(self.progress_reporter, "on_author", None)
127 if callable(fn):
128 fn(nickname=nickname, sec_uid=sec_uid)
129 except Exception as exc: # pragma: no cover — defensive
130 logger.debug("Progress on_author failed: %s", exc)
131
132 def _log_download_error(self, log_fn, message: str) -> None:
133 if self._download_error_log_count < self._download_error_log_limit:
134 log_fn(message)
135 elif self._download_error_log_count == self._download_error_log_limit:
136 logger.error("Too many download errors, suppressing further per-file logs...")
137 self._download_error_log_count += 1
138
139 def _download_headers(self, user_agent: Optional[str] = None) -> Dict[str, str]:
140 headers = {
141 "Referer": f"{self.api_client.BASE_URL}/",
142 "Origin": self.api_client.BASE_URL,
143 "Accept": "*/*",
144 }
145
146 headers["User-Agent"] = user_agent or self.api_client.headers.get("User-Agent", "")
147 return headers
148
149 @abstractmethod
150 async def download(self, parsed_url: Dict[str, Any]) -> DownloadResult:
151 pass
152
153 async def _should_download(self, aweme_id: str) -> bool:
154 in_local = self._is_locally_downloaded(aweme_id)
155 in_db = False
156 if self.database:
157 in_db = await self.database.is_downloaded(aweme_id)
158
159 if in_db and in_local:
160 return False
161
162 if in_db and not in_local:
163 logger.info(
164 "Aweme %s exists in database but media file not found locally, retry download",
165 aweme_id,
166 )
167 return True
168
169 if in_local:
170 logger.info("Aweme %s already exists locally, skipping", aweme_id)
171 return False
172
173 return True
174
175 def _is_locally_downloaded(self, aweme_id: str) -> bool:
176 if not aweme_id:
177 return False
178
179 if self._local_aweme_ids is None:
180 self._build_local_aweme_index()
181
182 if self._local_aweme_ids is None:
183 return False
184 return aweme_id in self._local_aweme_ids
185
186 def _build_local_aweme_index(self):
187 base_path = self.file_manager.base_path
188 aweme_ids: set[str] = set()
189
190 if base_path.exists():
191 for path in base_path.rglob("*"):
192 if not path.is_file():
193 continue
194 if path.suffix.lower() not in self._local_media_suffixes:
195 continue
196 try:
197 if path.stat().st_size <= 0:
198 continue
199 except OSError:
200 continue
201 for match in self._aweme_id_pattern.finditer(path.name):
202 aweme_ids.add(match.group(1))
203
204 self._local_aweme_ids = aweme_ids
205
206 def _mark_local_aweme_downloaded(self, aweme_id: str):
207 if not aweme_id:
208 return
209
210 if self._local_aweme_ids is None:
211 self._local_aweme_ids = set()
212 self._local_aweme_ids.add(aweme_id)
213
214 def _filter_by_time(self, aweme_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
215 start_time = self.config.get("start_time")
216 end_time = self.config.get("end_time")
217
218 if not start_time and not end_time:
219 return aweme_list
220
221 start_ts = (
222 int(datetime.strptime(start_time, "%Y-%m-%d").timestamp()) if start_time else None
223 )
224 end_ts = int(datetime.strptime(end_time, "%Y-%m-%d").timestamp()) if end_time else None
225
226 filtered: List[Dict[str, Any]] = []
227 for aweme in aweme_list:
228 create_time = aweme.get("create_time", 0)
229 if start_ts is not None and create_time < start_ts:
230 continue
231 if end_ts is not None and create_time > end_ts:
232 continue
233 filtered.append(aweme)
234
235 return filtered
236
237 def _limit_count(self, aweme_list: List[Dict[str, Any]], mode: str) -> List[Dict[str, Any]]:
238 number_config = self.config.get("number", {})
239 limit = number_config.get(mode, 0)
240
241 if limit > 0:
242 return aweme_list[:limit]
243 return aweme_list
244
245 async def _download_aweme_assets(
246 self,
247 aweme_data: Dict[str, Any],
248 author_name: str,
249 mode: Optional[str] = None,
250 *,
251 db_batch: Optional[List[Dict[str, Any]]] = None,
252 ) -> bool:
253 aweme_id = aweme_data.get("aweme_id")
254 if not aweme_id:
255 logger.error("Missing aweme_id in aweme data")
256 return False
257
258 desc = (aweme_data.get("desc", "no_title") or "").strip() or "no_title"
259 publish_ts, publish_date = self._resolve_publish_time(aweme_data.get("create_time"))
260 if not publish_date:
261 publish_date = datetime.now().strftime("%Y-%m-%d")
262 logger.warning(
263 "Aweme %s missing/invalid create_time, fallback to current date %s",
264 aweme_id,
265 publish_date,
266 )
267 media_type = self._detect_media_type(aweme_data)
268 template_context = build_aweme_context(
269 aweme_id=str(aweme_id),
270 title=desc,
271 author_name=author_name,
272 author_sec_uid=extract_author_sec_uid(aweme_data),
273 publish_date=publish_date,
274 publish_ts=publish_ts,
275 media_type=media_type,
276 mode=mode,
277 )
278 filename_template = self.config.get("filename_template") or DEFAULT_FILE_TEMPLATE
279 folder_template = self.config.get("folder_template") or DEFAULT_FOLDER_TEMPLATE
280 file_stem = render_template(
281 filename_template,
282 template_context,
283 fallback=f"{publish_date}_{aweme_id}",
284 )
285 folder_name = render_template(
286 folder_template,
287 template_context,
288 fallback=f"{publish_date}_{aweme_id}",
289 )
290
291 save_dir = self.file_manager.get_save_path(
292 author_name=author_name,
293 mode=mode,
294 aweme_title=desc,
295 aweme_id=aweme_id,
296 folderstyle=self.config.get("folderstyle", True),
297 download_date=publish_date,
298 folder_name=folder_name,
299 author_sec_uid=extract_author_sec_uid(aweme_data),
300 author_dir_style=self.config.get("author_dir") or "nickname",
301 )
302 downloaded_files: List[Path] = []
303
304 session = await self.api_client.get_session()
305 video_path: Optional[Path] = None
306
307 if media_type == "video":
308 video_info = self._build_no_watermark_url(aweme_data)
309 if not video_info:
310 logger.error("No playable video URL found for aweme %s", aweme_id)
311 return False
312
313 video_url, video_headers = video_info
314 video_path = save_dir / f"{file_stem}.mp4"
315 if not await self._download_with_retry(
316 video_url, video_path, session, headers=video_headers
317 ):
318 return False
319 downloaded_files.append(video_path)
320
321 if self.config.get("cover"):
322 cover_url = self._extract_first_url(aweme_data.get("video", {}).get("cover"))
323 if cover_url:
324 cover_path = save_dir / f"{file_stem}_cover.jpg"
325 if await self._download_with_retry(
326 cover_url,
327 cover_path,
328 session,
329 headers=self._download_headers(),
330 optional=True,
331 ):
332 downloaded_files.append(cover_path)
333
334 if self.config.get("music"):
335 music_url = self._extract_first_url(aweme_data.get("music", {}).get("play_url"))
336 if music_url:
337 music_path = save_dir / f"{file_stem}_music.mp3"
338 if await self._download_with_retry(
339 music_url,
340 music_path,
341 session,
342 headers=self._download_headers(),
343 optional=True,
344 ):
345 downloaded_files.append(music_path)
346
347 elif media_type == "gallery":
348 image_url_candidates = self._collect_image_url_candidates(aweme_data)
349 image_live_urls = self._collect_image_live_urls(aweme_data)
350 logger.info(
351 "Gallery aweme %s: %d image(s), %d live photo(s)",
352 aweme_id,
353 len(image_url_candidates),
354 len(image_live_urls),
355 )
356 if not image_url_candidates and not image_live_urls:
357 logger.error(
358 "No gallery assets found for aweme %s (aweme_type=%s, "
359 "has image_post_info=%s, has images=%s)",
360 aweme_id,
361 aweme_data.get("aweme_type"),
362 "image_post_info" in aweme_data,
363 "images" in aweme_data,
364 )
365 return False
366
367 for index, candidates in enumerate(image_url_candidates, start=1):
368 download_result: bool | Path = False
369 for image_url in candidates:
370 suffix = self._infer_image_extension(image_url)
371 image_path = save_dir / f"{file_stem}_{index}{suffix}"
372 download_result = await self._download_with_retry(
373 image_url,
374 image_path,
375 session,
376 headers=self._download_headers(),
377 prefer_response_content_type=True,
378 return_saved_path=True,
379 )
380 if download_result:
381 downloaded_files.append(
382 download_result if isinstance(download_result, Path) else image_path
383 )
384 break
385 if not download_result:
386 logger.error(f"Failed downloading image {index} for aweme {aweme_id}")
387 return False
388
389 for index, live_url in enumerate(image_live_urls, start=1):
390 suffix = Path(urlparse(live_url).path).suffix or ".mp4"
391 live_path = save_dir / f"{file_stem}_live_{index}{suffix}"
392 success = await self._download_with_retry(
393 live_url,
394 live_path,
395 session,
396 headers=self._download_headers(),
397 )
398 if not success:
399 logger.error(f"Failed downloading live image {index} for aweme {aweme_id}")
400 return False
401 downloaded_files.append(live_path)
402 else:
403 logger.error("Unsupported media type for aweme %s: %s", aweme_id, media_type)
404 return False
405
406 if self.config.get("avatar"):
407 author = aweme_data.get("author", {})
408 avatar_url = self._extract_first_url(author.get("avatar_larger"))
409 if avatar_url:
410 avatar_path = save_dir / f"{file_stem}_avatar.jpg"
411 if await self._download_with_retry(
412 avatar_url,
413 avatar_path,
414 session,
415 headers=self._download_headers(),
416 optional=True,
417 ):
418 downloaded_files.append(avatar_path)
419
420 if self.config.get("json"):
421 json_path = save_dir / f"{file_stem}_data.json"
422 if await self.metadata_handler.save_metadata(aweme_data, json_path):
423 downloaded_files.append(json_path)
424
425 comments_cfg = self.config.get("comments") or {}
426 if isinstance(comments_cfg, dict) and comments_cfg.get("enabled"):
427 from core.comments_collector import CommentsCollector
428
429 collector = CommentsCollector(
430 self.api_client,
431 self.metadata_handler,
432 include_replies=bool(comments_cfg.get("include_replies", False)),
433 max_comments=int(comments_cfg.get("max_comments", 0) or 0),
434 page_size=int(comments_cfg.get("page_size", 20) or 20),
435 )
436 comments_path = save_dir / f"{file_stem}_comments.json"
437 saved = await collector.collect_and_save(aweme_id, comments_path)
438 if saved is not None:
439 downloaded_files.append(comments_path)
440
441 author = aweme_data.get("author", {})
442 if self.database:
443 metadata_json = json.dumps(aweme_data, ensure_ascii=False)
444 record = {
445 "aweme_id": aweme_id,
446 "aweme_type": media_type,
447 "title": desc,
448 "author_id": author.get("uid"),
449 "author_name": author.get("nickname", author_name),
450 "create_time": aweme_data.get("create_time"),
451 "file_path": str(save_dir),
452 "metadata": metadata_json,
453 # Attach sec_uid onto the payload so both the batched path
454 # (add_aweme_batch iterates `record["author_sec_uid"]`) and
455 # the single-write path (add_aweme reads the payload as
456 # fallback when the kwarg is None) pick it up identically.
457 "author_sec_uid": extract_author_sec_uid(aweme_data),
458 }
459 # Caller may opt into batched DB writes by passing a list; we just
460 # accumulate the record and let the caller commit them all at once.
461 if db_batch is not None:
462 db_batch.append(record)
463 else:
464 await self.database.add_aweme(record)
465
466 manifest_record = {
467 "date": publish_date,
468 "aweme_id": aweme_id,
469 "author_name": author.get("nickname", author_name),
470 "desc": desc,
471 "media_type": media_type,
472 "tags": self._extract_tags(aweme_data),
473 "file_names": [path.name for path in downloaded_files],
474 "file_paths": [self._to_manifest_path(path) for path in downloaded_files],
475 }
476 if publish_ts:
477 manifest_record["publish_timestamp"] = publish_ts
478 await self.metadata_handler.append_download_manifest(
479 self.file_manager.base_path, manifest_record
480 )
481
482 if media_type == "video" and video_path is not None:
483 transcript_result = await self.transcript_manager.process_video(
484 video_path, aweme_id=aweme_id
485 )
486 transcript_status = transcript_result.get("status")
487 if transcript_status == "skipped":
488 logger.info(
489 "Transcript skipped for aweme %s: %s",
490 aweme_id,
491 transcript_result.get("reason", "unknown"),
492 )
493 elif transcript_status == "failed":
494 logger.warning(
495 "Transcript failed for aweme %s: %s",
496 aweme_id,
497 transcript_result.get("error", "unknown"),
498 )
499
500 self._mark_local_aweme_downloaded(aweme_id)
501 logger.info("Downloaded %s: %s (%s)", media_type, desc, aweme_id)
502 return True
503
504 async def _download_with_retry(
505 self,
506 url: str,
507 save_path: Path,
508 session,
509 *,
510 headers: Optional[Dict[str, str]] = None,
511 optional: bool = False,
512 prefer_response_content_type: bool = False,
513 return_saved_path: bool = False,
514 ) -> bool | Path:
515 async def _task():
516 download_result = await self.file_manager.download_file(
517 url,
518 save_path,
519 session,
520 headers=headers,
521 proxy=getattr(self.api_client, "proxy", None),
522 prefer_response_content_type=prefer_response_content_type,
523 return_saved_path=return_saved_path,
524 )
525 if not download_result:
526 raise RuntimeError(f"Download failed for {url}")
527 return download_result
528
529 try:
530 return await self.retry_handler.execute_with_retry(_task)
531 except Exception as error:
532 log_fn = logger.warning if optional else logger.error
533 self._log_download_error(
534 log_fn,
535 f"Download error for {save_path.name}: {error}",
536 )
537 return False
538
539 # aweme_type codes that indicate image/note content
540 _GALLERY_AWEME_TYPES = {2, 68, 150}
541 _PLAY_ADDR_KEYS = (
542 "play_addr_h264",
543 "play_addr_265",
544 "play_addr_256",
545 "play_addr",
546 )
547
548 def _detect_media_type(self, aweme_data: Dict[str, Any]) -> str:
549 if self._iter_gallery_items(aweme_data):
550 return "gallery"
551 aweme_type = aweme_data.get("aweme_type")
552 if isinstance(aweme_type, int) and aweme_type in self._GALLERY_AWEME_TYPES:
553 video = aweme_data.get("video") if isinstance(aweme_data.get("video"), dict) else {}
554 if self._has_video_source(video):
555 logger.info(
556 "Detected note video via aweme_type=%s for aweme %s",
557 aweme_type,
558 aweme_data.get("aweme_id"),
559 )
560 return "video"
561 logger.info(
562 "Detected gallery via aweme_type=%s for aweme %s",
563 aweme_type,
564 aweme_data.get("aweme_id"),
565 )
566 return "gallery"
567 return "video"
568
569 def _build_no_watermark_url(
570 self, aweme_data: Dict[str, Any]
571 ) -> Optional[Tuple[str, Dict[str, str]]]:
572 video = aweme_data.get("video", {})
573 quality = str(self.config.get("video_quality") or "highest")
574 play_addr = self._pick_preferred_play_addr(video, quality) or {}
575 url_candidates = [c for c in (play_addr.get("url_list") or []) if c]
576 url_candidates.sort(key=lambda u: 0 if "watermark=0" in u else 1)
577
578 fallback_candidate: Optional[Tuple[str, Dict[str, str]]] = None
579 watermarked_candidate: Optional[Tuple[str, Dict[str, str]]] = None
580
581 for candidate in url_candidates:
582 parsed = urlparse(candidate)
583 headers = self._download_headers()
584 is_watermarked = self._is_watermarked_media_url(candidate)
585
586 if parsed.netloc.endswith("douyin.com"):
587 if "X-Bogus=" not in candidate:
588 signed_url, ua = self.api_client.sign_url(candidate)
589 headers = self._download_headers(user_agent=ua)
590 if is_watermarked:
591 watermarked_candidate = watermarked_candidate or (
592 signed_url,
593 headers,
594 )
595 continue
596 return signed_url, headers
597 if is_watermarked:
598 watermarked_candidate = watermarked_candidate or (candidate, headers)
599 continue
600 return candidate, headers
601
602 if is_watermarked:
603 watermarked_candidate = watermarked_candidate or (candidate, headers)
604 else:
605 fallback_candidate = fallback_candidate or (candidate, headers)
606
607 # Prefer direct CDN URLs (e.g. douyinvod.com) over the /aweme/v1/play/
608 # signed endpoint: the latter redirects to a URL that returns 403 Forbidden.
609 if fallback_candidate:
610 return fallback_candidate
611
612 uri = play_addr.get("uri") or video.get("vid") or video.get("download_addr", {}).get("uri")
613 if uri:
614 # Douyin /aweme/v1/play/ accepts a limited set of ratio strings.
615 # Map "highest"/"lowest" to 1080p/540p respectively; pass through
616 # recognised <N>p values; fall back to 1080p for unknowns so the
617 # legacy default is preserved.
618 ratio_map = {
619 "highest": "1080p",
620 "lowest": "540p",
621 }
622 ratio = ratio_map.get(quality, quality if quality in self._QUALITY_TARGET_WIDTH else "1080p")
623 params = {
624 "video_id": uri,
625 "ratio": ratio,
626 "line": "0",
627 "is_play_url": "1",
628 "watermark": "0",
629 "source": "PackSourceEnum_PUBLISH",
630 }
631 signed_url, ua = self.api_client.build_signed_path("/aweme/v1/play/", params)
632 return signed_url, self._download_headers(user_agent=ua)
633
634 if watermarked_candidate:
635 return watermarked_candidate
636
637 return None
638
639 # 视频画质选择支持的规格名称。用于 `_pick_play_addr_by_quality` 按
640 # 分辨率(width)匹配最接近的档位;匹配不到时自动降级到最高可用档。
641 _QUALITY_TARGET_WIDTH: Dict[str, int] = {
642 "1440p": 2560,
643 "1080p": 1920,
644 "720p": 1280,
645 "540p": 960,
646 "480p": 854,
647 "360p": 640,
648 }
649
650 @staticmethod
651 def _pick_highest_quality_play_addr(video: Dict[str, Any]) -> Optional[Dict[str, Any]]:
652 """Shortcut for highest-quality selection (backward-compatible).
653
654 Kept for older callers; new code should use
655 :meth:`_pick_play_addr_by_quality` with an explicit quality string.
656 """
657 return BaseDownloader._pick_play_addr_by_quality(video, "highest")
658
659 @staticmethod
660 def _pick_play_addr_by_quality(
661 video: Dict[str, Any], quality: str = "highest"
662 ) -> Optional[Dict[str, Any]]:
663 """从 video.bit_rate 多档率中按目标画质挑选 play_addr。
664
665 抖音 API 的 ``video.bit_rate`` 是按质量排序的字典列表,每项含
666 ``bit_rate``(码率)与 ``play_addr``(内含 ``url_list`` 与 ``width``)。
667
668 - ``highest`` / 未知值 / 空:取 bit_rate 最大的档;同码率按 width 决胜。
669 - ``lowest``:取 bit_rate 最小的档;同码率按 width 小者优先。
670 - ``<N>p``(如 ``1080p``):按 width 最接近目标值的档;完全没有 bit_rate
671 数据时返回 ``None``,让调用方回退到 ``video.play_addr``。
672 """
673 bit_rates = video.get("bit_rate") if isinstance(video, dict) else None
674 if not isinstance(bit_rates, list) or not bit_rates:
675 return None
676
677 # Normalise + collect valid (bit_rate, width, play_addr) triples.
678 entries: List[Tuple[int, int, Dict[str, Any]]] = []
679 for entry in bit_rates:
680 if not isinstance(entry, dict):
681 continue
682 play_addr = entry.get("play_addr")
683 if not isinstance(play_addr, dict):
684 continue
685 try:
686 br = int(entry.get("bit_rate") or 0)
687 except (TypeError, ValueError):
688 br = 0
689 width = int(play_addr.get("width") or entry.get("width") or 0)
690 entries.append((br, width, play_addr))
691 if not entries:
692 return None
693
694 normalised = (quality or "highest").strip().lower()
695
696 if normalised == "lowest":
697 # Lowest bit_rate; tie-break by smaller width.
698 entries.sort(key=lambda t: (t[0], t[1]))
699 return entries[0][2]
700
701 target_width = BaseDownloader._QUALITY_TARGET_WIDTH.get(normalised)
702 if target_width is not None:
703 # Closest width to target; tie-break by higher bit_rate so we
704 # don't accidentally pick a re-encoded low-bitrate copy.
705 entries.sort(key=lambda t: (abs(t[1] - target_width), -t[0]))
706 return entries[0][2]
707
708 # Default / "highest": highest bit_rate, tie-break by higher width.
709 entries.sort(key=lambda t: (-t[0], -t[1]))
710 return entries[0][2]
711
712 @staticmethod
713 def _pick_preferred_play_addr(
714 video: Dict[str, Any], quality: str = "highest"
715 ) -> Optional[Dict[str, Any]]:
716 preferred = BaseDownloader._pick_play_addr_by_quality(video, quality)
717 if preferred:
718 return preferred
719 if not isinstance(video, dict):
720 return None
721 primary = video.get("play_addr")
722 if isinstance(primary, dict) and primary.get("uri"):
723 return primary
724 for key in BaseDownloader._PLAY_ADDR_KEYS:
725 candidate = video.get(key)
726 if isinstance(candidate, dict) and (
727 BaseDownloader._extract_first_url(candidate) or candidate.get("uri")
728 ):
729 return candidate
730 return None
731
732 @staticmethod
733 def _has_video_source(video: Dict[str, Any]) -> bool:
734 if BaseDownloader._pick_preferred_play_addr(video):
735 return True
736 if not isinstance(video, dict):
737 return False
738 return bool(
739 video.get("vid")
740 or (isinstance(video.get("download_addr"), dict) and video["download_addr"].get("uri"))
741 )
742
743 def _collect_image_urls(self, aweme_data: Dict[str, Any]) -> List[str]:
744 return [
745 candidates[0]
746 for candidates in self._collect_image_url_candidates(aweme_data)
747 if candidates
748 ]
749
750 def _collect_image_url_candidates(self, aweme_data: Dict[str, Any]) -> List[List[str]]:
751 image_urls = []
752 gallery_items = self._iter_gallery_items(aweme_data)
753 for item in gallery_items:
754 if not isinstance(item, dict):
755 continue
756 candidates = self._collect_media_urls(
757 item.get("watermark_free_download_url_list"),
758 item,
759 item.get("origin_image"),
760 item.get("display_image"),
761 item.get("download_url"),
762 item.get("download_addr"),
763 item.get("download_url_list"),
764 item.get("owner_watermark_image"),
765 )
766 if candidates:
767 image_urls.append(candidates)
768 if not image_urls:
769 logger.warning(
770 "No image URLs extracted for aweme %s; gallery items count=%d",
771 aweme_data.get("aweme_id"),
772 len(gallery_items),
773 )
774 return image_urls
775
776 def _collect_image_live_urls(self, aweme_data: Dict[str, Any]) -> List[str]:
777 live_urls: List[str] = []
778 quality = str(self.config.get("video_quality") or "highest")
779 for item in self._iter_gallery_items(aweme_data):
780 if not isinstance(item, dict):
781 continue
782 video = item.get("video") if isinstance(item.get("video"), dict) else {}
783 # 实况图同样会有 bit_rate 多档,按配置的画质偏好选择对应档位。
784 preferred_play_addr = self._pick_preferred_play_addr(video, quality)
785 live_url = self._pick_first_media_url(
786 preferred_play_addr,
787 video.get("play_addr_h264"),
788 video.get("play_addr_265"),
789 video.get("play_addr_256"),
790 video.get("play_addr"),
791 video.get("download_addr"),
792 item.get("video_play_addr"),
793 item.get("video_download_addr"),
794 )
795 if live_url:
796 live_urls.append(live_url)
797 return self._deduplicate_urls(live_urls)
798
799 @staticmethod
800 def _iter_gallery_items(aweme_data: Dict[str, Any]) -> List[Any]:
801 image_post = aweme_data.get("image_post_info")
802 if isinstance(image_post, dict):
803 for key in ("images", "image_list"):
804 candidate = image_post.get(key)
805 if isinstance(candidate, list) and candidate:
806 return candidate
807 images = aweme_data.get("images") or aweme_data.get("image_list") or []
808 if isinstance(images, list):
809 return images
810 return []
811
812 @staticmethod
813 def _deduplicate_urls(urls: List[str]) -> List[str]:
814 deduped: List[str] = []
815 seen: set[str] = set()
816 for url in urls:
817 if not url or url in seen:
818 continue
819 seen.add(url)
820 deduped.append(url)
821 return deduped
822
823 @staticmethod
824 def _pick_first_media_url(*sources: Any) -> Optional[str]:
825 for source in sources:
826 candidate = BaseDownloader._extract_first_url(source)
827 if candidate:
828 return candidate
829 return None
830
831 @staticmethod
832 def _collect_media_urls(*sources: Any) -> List[str]:
833 urls: List[str] = []
834 seen: set[str] = set()
835 for source in sources:
836 for candidate in sorted(
837 BaseDownloader._extract_urls(source),
838 key=BaseDownloader._media_url_priority,
839 ):
840 if candidate in seen:
841 continue
842 seen.add(candidate)
843 urls.append(candidate)
844 return urls
845
846 @staticmethod
847 def _media_url_priority(url: str) -> int:
848 normalized = url.lower()
849 path = (urlparse(url).path or "").lower()
850 score = 100 if BaseDownloader._is_watermarked_media_url(normalized) else 0
851 return score + (1 if ".webp" in path else 0)
852
853 @staticmethod
854 def _is_watermarked_media_url(url: str) -> bool:
855 normalized = url.lower()
856 watermark_hints = (
857 "tplv-dy-water",
858 "dy-water",
859 "owner_watermark",
860 "watermark_image",
861 "watermark=1",
862 "playwm",
863 )
864 return any(hint in normalized for hint in watermark_hints)
865
866 @staticmethod
867 def _extract_first_url(source: Any) -> Optional[str]:
868 urls = BaseDownloader._extract_urls(source)
869 return urls[0] if urls else None
870
871 @staticmethod
872 def _extract_urls(source: Any) -> List[str]:
873 if isinstance(source, dict):
874 url_list = source.get("url_list") or source.get("urlList")
875 if isinstance(url_list, list) and url_list:
876 return [item for item in url_list if isinstance(item, str) and item]
877 elif isinstance(source, list) and source:
878 return [item for item in source if isinstance(item, str) and item]
879 elif isinstance(source, str) and source:
880 return [source]
881 return []
882
883 @staticmethod
884 def _infer_image_extension(image_url: str) -> str:
885 allowed_exts = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
886 if not image_url:
887 return ".jpg"
888
889 image_path = (urlparse(image_url).path or "").lower()
890 raw_suffix = Path(image_path).suffix.lower()
891 if raw_suffix in allowed_exts:
892 return raw_suffix
893
894 matches = re.findall(r"\.(?:jpe?g|png|webp|gif)(?=[^a-z0-9]|$)", image_path)
895 if matches:
896 return matches[-1].lower()
897
898 return ".jpg"
899
900 @staticmethod
901 def _resolve_publish_time(create_time: Any) -> Tuple[Optional[int], str]:
902 if create_time in (None, ""):
903 return None, ""
904
905 try:
906 publish_ts = int(create_time)
907 if publish_ts <= 0:
908 return None, ""
909 return publish_ts, datetime.fromtimestamp(publish_ts).strftime("%Y-%m-%d")
910 except (TypeError, ValueError, OSError, OverflowError):
911 return None, ""
912
913 @staticmethod
914 def _extract_tags(aweme_data: Dict[str, Any]) -> List[str]:
915 tags: List[str] = []
916
917 def _append_tag(raw_tag: Any):
918 if not raw_tag:
919 return
920 normalized_tag = str(raw_tag).strip().lstrip("#")
921 if normalized_tag and normalized_tag not in tags:
922 tags.append(normalized_tag)
923
924 for item in aweme_data.get("text_extra") or []:
925 if not isinstance(item, dict):
926 continue
927 _append_tag(item.get("hashtag_name"))
928 _append_tag(item.get("tag_name"))
929
930 for item in aweme_data.get("cha_list") or []:
931 if not isinstance(item, dict):
932 continue
933 _append_tag(item.get("cha_name"))
934 _append_tag(item.get("name"))
935
936 desc = aweme_data.get("desc") or ""
937 for hashtag in re.findall(r"#([^\s#]+)", desc):
938 _append_tag(hashtag)
939
940 return tags
941
942 def _to_manifest_path(self, path: Path) -> str:
943 try:
944 return str(path.relative_to(self.file_manager.base_path))
945 except ValueError:
946 return str(path)
947
947 lines PYTHON