返回 douyin-downloader
comments_collector.py
根目录 / core / comments_collector.py
1 """评论采集:针对单个作品拉取全部评论(可选含二级回复),导出为 JSON。
2
3 设计要点:
4 - 复用 DouyinAPIClient 的分页请求与签名
5 - 与下载流程解耦:作为独立的 helper,由 BaseDownloader 在保存媒体后按需调用
6 - 输出位置:与媒体同目录,文件名 `{file_stem}_comments.json`
7 - 支持上限 max_comments(默认 0 = 不限)和 include_replies
8 """
9
10 from __future__ import annotations
11
12 import asyncio
13 from pathlib import Path
14 from typing import TYPE_CHECKING, Any, Dict, List, Optional
15
16 from utils.logger import setup_logger
17
18 if TYPE_CHECKING: # pragma: no cover
19 from core.api_client import DouyinAPIClient
20 from storage.metadata_handler import MetadataHandler
21
22 logger = setup_logger("CommentsCollector")
23
24
25 class CommentsCollector:
26 def __init__(
27 self,
28 api_client: "DouyinAPIClient",
29 metadata_handler: "MetadataHandler",
30 *,
31 include_replies: bool = False,
32 max_comments: int = 0,
33 page_size: int = 20,
34 retry_delay_seconds: float = 1.0,
35 ):
36 self.api_client = api_client
37 self.metadata_handler = metadata_handler
38 self.include_replies = include_replies
39 self.max_comments = int(max_comments or 0)
40 self.page_size = max(1, int(page_size or 20))
41 self.retry_delay_seconds = float(retry_delay_seconds or 1.0)
42
43 async def collect_and_save(self, aweme_id: str, output_path: Path) -> Optional[Dict[str, Any]]:
44 """抓取评论并写入 output_path,失败时返回 None。"""
45 comments = await self.collect(aweme_id)
46 if comments is None:
47 return None
48
49 payload = {
50 "aweme_id": aweme_id,
51 "count": len(comments),
52 "include_replies": self.include_replies,
53 "comments": comments,
54 }
55 # MetadataHandler.save_metadata 内部已吞异常并返回 bool
56 saved = await self.metadata_handler.save_metadata(payload, output_path)
57 if not saved:
58 logger.warning("Failed to save comments for %s to %s", aweme_id, output_path)
59 return None
60 return payload
61
62 async def collect(self, aweme_id: str) -> Optional[List[Dict[str, Any]]]:
63 """抓取评论列表(不写盘),失败返回 None。"""
64 all_comments: List[Dict[str, Any]] = []
65 cursor = 0
66 seen_ids: set = set()
67
68 while True:
69 try:
70 page = await self.api_client.get_aweme_comments(
71 aweme_id,
72 cursor=cursor,
73 count=self.page_size,
74 include_replies=self.include_replies,
75 )
76 except Exception as exc:
77 logger.warning(
78 "Comments fetch error for %s cursor=%s: %s",
79 aweme_id,
80 cursor,
81 exc,
82 )
83 return None
84
85 items = page.get("items") or []
86 if not items:
87 break
88
89 for item in items:
90 if not isinstance(item, dict):
91 continue
92 cid = item.get("cid") or item.get("comment_id")
93 key = str(cid) if cid else None
94 if key and key in seen_ids:
95 continue
96 if key:
97 seen_ids.add(key)
98 all_comments.append(item)
99 if 0 < self.max_comments <= len(all_comments):
100 return all_comments[: self.max_comments]
101
102 if not page.get("has_more"):
103 break
104 next_cursor = page.get("max_cursor") or 0
105 if next_cursor == cursor:
106 # cursor 未推进但服务器称 has_more=True:可能是接口变更或异常返回,
107 # 升级为 warning 便于线上观察。
108 logger.warning(
109 "Comments cursor stuck (aweme=%s, cursor=%s, has_more=True); "
110 "stopping to avoid infinite loop.",
111 aweme_id,
112 cursor,
113 )
114 break
115 cursor = next_cursor
116 await asyncio.sleep(self.retry_delay_seconds * 0.1) # 轻度节流
117
118 return all_comments
119
119 lines PYTHON