返回 douyin-downloader
discovery.py
根目录 / core / discovery.py
1 """热榜 / 搜索数据采集模块。
2
3 仅负责数据落盘(JSONL),不下载媒体本体。用户拿到结果后可挑感兴趣的链接再丢进下载器。
4 """
5
6 from __future__ import annotations
7
8 import asyncio
9 import json
10 from datetime import datetime
11 from pathlib import Path
12 from typing import TYPE_CHECKING, Any, Dict, List, Optional
13
14 import aiofiles
15
16 from utils.logger import setup_logger
17
18 if TYPE_CHECKING: # pragma: no cover
19 from core.api_client import DouyinAPIClient
20
21 logger = setup_logger("Discovery")
22
23
24 async def _write_jsonl(path: Path, items: List[Dict[str, Any]]) -> None:
25 path.parent.mkdir(parents=True, exist_ok=True)
26 async with aiofiles.open(path, "w", encoding="utf-8") as f:
27 for item in items:
28 await f.write(json.dumps(item, ensure_ascii=False))
29 await f.write("\n")
30
31
32 async def dump_hot_board(
33 api_client: "DouyinAPIClient",
34 output_dir: Path,
35 *,
36 limit: int = 0,
37 ) -> Dict[str, Any]:
38 """抓取抖音热搜榜并写入 output_dir/hot_board/{ts}.jsonl。
39
40 Args:
41 limit: 上限(0=全部)
42 Returns:
43 dict(items, path)
44 """
45 page = await api_client.get_hot_search_board()
46 items = list(page.get("items") or [])
47 if limit and limit > 0:
48 items = items[:limit]
49
50 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
51 out_path = output_dir / "hot_board" / f"{ts}.jsonl"
52 await _write_jsonl(out_path, items)
53 logger.info("Hot board snapshot saved: %s items -> %s", len(items), out_path)
54 return {"items": items, "path": str(out_path), "count": len(items)}
55
56
57 async def search_and_dump(
58 api_client: "DouyinAPIClient",
59 keyword: str,
60 output_dir: Path,
61 *,
62 max_items: int = 50,
63 page_size: int = 10,
64 sort_type: int = 0,
65 publish_time: int = 0,
66 rate_limiter: Optional[Any] = None,
67 ) -> Dict[str, Any]:
68 """搜索作品并将结果写入 output_dir/search/{keyword}_{ts}.jsonl。
69
70 Args:
71 max_items: 最多累计条数(0=不限,建议设置以防失控)
72 """
73 accumulated: List[Dict[str, Any]] = []
74 offset = 0
75 seen_ids: set = set()
76
77 while True:
78 if rate_limiter is not None:
79 try:
80 await rate_limiter.acquire()
81 except Exception: # noqa: BLE001
82 pass
83
84 page = await api_client.search_aweme(
85 keyword,
86 offset=offset,
87 count=page_size,
88 sort_type=sort_type,
89 publish_time=publish_time,
90 )
91 items = page.get("items") or []
92 if not items:
93 break
94
95 for item in items:
96 if not isinstance(item, dict):
97 continue
98 aweme_id = str(item.get("aweme_id") or "")
99 if aweme_id in seen_ids:
100 continue
101 if aweme_id:
102 seen_ids.add(aweme_id)
103 accumulated.append(item)
104 if 0 < max_items <= len(accumulated):
105 break
106
107 if 0 < max_items <= len(accumulated):
108 break
109 if not page.get("has_more"):
110 break
111 next_offset = int(page.get("max_cursor") or 0)
112 if next_offset == offset:
113 break
114 offset = next_offset
115 await asyncio.sleep(0.1)
116
117 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
118 safe_keyword = "".join(c if c.isalnum() else "_" for c in keyword)[:40] or "query"
119 out_path = output_dir / "search" / f"{safe_keyword}_{ts}.jsonl"
120 await _write_jsonl(out_path, accumulated)
121 logger.info("Search '%s' saved: %s items -> %s", keyword, len(accumulated), out_path)
122 return {
123 "keyword": keyword,
124 "items": accumulated,
125 "count": len(accumulated),
126 "path": str(out_path),
127 }
128
128 lines PYTHON