search.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. """关键词搜索:query → content_id 列表(翻页 + 去重 + 截断)。
  2. 接口(实测 2026-06-26,host = settings.aiddit_crawler_base_url):
  3. 小红书 POST /crawler/xiao_hong_shu/keyword
  4. body{keyword, content_type:"图文"|"视频", sort_type:"综合", publish_time:"", cursor:""}
  5. → {code:0, data:{has_more, next_cursor, data:[{id:<content_id>, note_card:{...}}]}}
  6. 抖音 POST /crawler/dou_yin/keyword
  7. body{keyword, content_type:"视频", sort_type:"综合排序", publish_time:"不限",
  8. cursor:"0", account_id:"771431222"} # ← 缺 account_id 会 code 10000
  9. → {code:0, data:{has_more, next_cursor, data:[{aweme_id:<content_id>, ...}]}}
  10. 各平台请求参数 / id 字段不同,集中在 PLATFORM_SEARCH 表里。
  11. 搜索只给预览;全文/全图/视频仍需 crawler.fetch_post_detail 逐 id 取详情(detail 只需 content_id)。
  12. parse_search_response 纯函数可离线测;search_keyword 负责 HTTP + 翻页。
  13. """
  14. from __future__ import annotations
  15. import re
  16. from typing import Any, Optional
  17. from urllib.parse import urljoin
  18. import httpx
  19. from core.config import Settings
  20. from acquisition.crawler import RateLimiter
  21. # 每平台的搜索参数差异(path / 条目 id 字段 / 排序 / 发布时间 / 起始 cursor / account_id)
  22. PLATFORM_SEARCH = {
  23. "xiaohongshu": {
  24. "path": "/crawler/xiao_hong_shu/keyword", "id_key": "id",
  25. "sort_type": "综合", "publish_time": "", "cursor0": "", "account_id": None,
  26. },
  27. "douyin": {
  28. "path": "/crawler/dou_yin/keyword", "id_key": "aweme_id",
  29. "sort_type": "综合排序", "publish_time": "不限", "cursor0": "0",
  30. # account_id 改由 settings.piaoquantv_douyin_account_id 提供(抖音走 piaoquantv 后端);缺它搜索报错
  31. "account_id": None,
  32. },
  33. }
  34. class SearchError(RuntimeError):
  35. pass
  36. def parse_search_response(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[str], bool, str]:
  37. """信封 {code,msg,data:{has_more,next_cursor,data:[...]}} → (content_ids, has_more, next_cursor)。
  38. 条目 id 字段按平台取(小红书 id / 抖音 aweme_id)。code!=0 抛 SearchError。"""
  39. if not isinstance(response, dict):
  40. raise SearchError("bad_response: not a dict")
  41. code = response.get("code")
  42. if code not in (0, "0"):
  43. raise SearchError(f"business_error: code={code} msg={response.get('msg')}")
  44. data = response.get("data") or {}
  45. items = data.get("data") or []
  46. id_key = (PLATFORM_SEARCH.get(platform) or {}).get("id_key", "id")
  47. ids = [it.get(id_key) for it in items if isinstance(it, dict) and it.get(id_key)]
  48. return ids, bool(data.get("has_more")), str(data.get("next_cursor") or "")
  49. def search_keyword(
  50. keyword: str,
  51. *,
  52. platform: str = "xiaohongshu",
  53. content_type: str = "图文",
  54. sort_type: Optional[str] = None, # None → 平台默认(小红书:综合 / 抖音:综合排序)
  55. publish_time: Optional[str] = None, # None → 平台默认(抖音:不限)
  56. account_id: Optional[str] = None, # None → 平台默认(抖音:771431222;小红书无)
  57. limit: int = 5,
  58. settings: Optional[Settings] = None,
  59. http_client: Any = None,
  60. rate_limiter: Optional[RateLimiter] = None,
  61. env_file: str = ".env",
  62. max_pages: int = 10,
  63. ) -> list[str]:
  64. """搜索 → content_id 列表(翻页直到够 limit 或 has_more=False;按 id 去重、截断 limit)。
  65. 平台专属参数(path/sort_type/publish_time/起始cursor/account_id)取自 PLATFORM_SEARCH,
  66. 显式入参可覆盖。复用 crawler.RateLimiter('{platform}_keyword' bucket)。失败抛 SearchError。"""
  67. cfg = PLATFORM_SEARCH.get(platform)
  68. if not cfg:
  69. raise SearchError(f"unsupported platform: {platform}")
  70. settings = settings or Settings.from_env(env_file)
  71. sort_type = cfg["sort_type"] if sort_type is None else sort_type
  72. publish_time = cfg["publish_time"] if publish_time is None else publish_time
  73. is_douyin = platform == "douyin"
  74. if account_id is None:
  75. account_id = settings.piaoquantv_douyin_account_id if is_douyin else cfg["account_id"]
  76. base_url = settings.piaoquantv_douyin_base_url if is_douyin else settings.aiddit_crawler_base_url
  77. rate_limiter = rate_limiter or RateLimiter()
  78. owns_client = http_client is None
  79. client = http_client or httpx.Client()
  80. out: list[str] = []
  81. seen: set[str] = set()
  82. cursor = cfg["cursor0"]
  83. try:
  84. for _ in range(max_pages):
  85. rate_limiter.wait(f"{platform}_keyword")
  86. url = urljoin(base_url, cfg["path"])
  87. body = {"keyword": keyword, "content_type": content_type,
  88. "sort_type": sort_type, "publish_time": publish_time, "cursor": cursor}
  89. if account_id:
  90. body["account_id"] = account_id # 抖音必带;小红书不带
  91. if is_douyin:
  92. body["cookie_batch"] = settings.piaoquantv_douyin_cookie_batch # piaoquantv 抖音必带
  93. try:
  94. resp = client.post(url, json=body,
  95. headers={"Content-Type": "application/json"},
  96. timeout=settings.crawler_timeout)
  97. resp.raise_for_status()
  98. data = resp.json()
  99. except httpx.HTTPError as exc:
  100. raise SearchError(f"http_error: {exc}") from exc
  101. except ValueError as exc:
  102. raise SearchError("bad_json") from exc
  103. ids, has_more, next_cursor = parse_search_response(data, platform=platform)
  104. for cid in ids:
  105. if cid not in seen:
  106. seen.add(cid)
  107. out.append(cid)
  108. if len(out) >= limit:
  109. return out
  110. if not has_more or not next_cursor or next_cursor == cursor:
  111. break
  112. cursor = next_cursor
  113. finally:
  114. if owns_client:
  115. client.close()
  116. return out[:limit]
  117. # ---------- 微信公众号搜索(回的是带 url/封面的文章,不是纯 id,故单列)----------
  118. _EM = re.compile(r"<[^>]+>")
  119. def parse_weixin(response: Any, *, limit: int = 5) -> list[dict]:
  120. """微信搜索回包 {code,data:{data:[{title,url,cover_url,nick_name,time}]}} → 文章列表(title 去 <em> 标记)。"""
  121. if not isinstance(response, dict):
  122. raise SearchError("bad_response: not a dict")
  123. if response.get("code") not in (0, "0"):
  124. raise SearchError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
  125. items = ((response.get("data") or {}).get("data")) or []
  126. out: list[dict] = []
  127. for it in items:
  128. if not isinstance(it, dict) or not it.get("url"):
  129. continue
  130. out.append({"title": _EM.sub("", it.get("title", "")).strip(),
  131. "url": it.get("url", ""), "cover_url": it.get("cover_url") or "",
  132. "nick_name": it.get("nick_name") or "", "time": it.get("time") or ""})
  133. if len(out) >= limit:
  134. break
  135. return out
  136. def search_weixin(
  137. keyword: str,
  138. *,
  139. limit: int = 5,
  140. settings: Optional[Settings] = None,
  141. http_client: Any = None,
  142. rate_limiter: Optional[RateLimiter] = None,
  143. env_file: str = ".env",
  144. ) -> list[dict]:
  145. """微信公众号搜索:query → 文章列表(带 url/封面,无需 detail)。失败抛 SearchError。"""
  146. settings = settings or Settings.from_env(env_file)
  147. rate_limiter = rate_limiter or RateLimiter()
  148. owns_client = http_client is None
  149. client = http_client or httpx.Client()
  150. try:
  151. rate_limiter.wait("weixin_keyword")
  152. url = urljoin(settings.aiddit_crawler_base_url, "/crawler/wei_xin/keyword")
  153. try:
  154. resp = client.post(url, json={"keyword": keyword, "cursor": "0"},
  155. headers={"Content-Type": "application/json"},
  156. timeout=settings.crawler_timeout)
  157. resp.raise_for_status()
  158. data = resp.json()
  159. except httpx.HTTPError as exc:
  160. raise SearchError(f"http_error: {exc}") from exc
  161. except ValueError as exc:
  162. raise SearchError("bad_json") from exc
  163. return parse_weixin(data, limit=limit)
  164. finally:
  165. if owns_client:
  166. client.close()
  167. def fetch_weixin_detail(
  168. url: str,
  169. *,
  170. settings: Optional[Settings] = None,
  171. http_client: Any = None,
  172. rate_limiter: Optional[RateLimiter] = None,
  173. env_file: str = ".env",
  174. ) -> tuple[str, list[str]]:
  175. """微信公众号文章详情:content_link → (body_text, image_urls)。实测不需 token。失败抛 SearchError。"""
  176. settings = settings or Settings.from_env(env_file)
  177. rate_limiter = rate_limiter or RateLimiter()
  178. owns_client = http_client is None
  179. client = http_client or httpx.Client()
  180. try:
  181. rate_limiter.wait("weixin_detail")
  182. api = urljoin(settings.aiddit_crawler_base_url, "/crawler/wei_xin/detail")
  183. try:
  184. resp = client.post(api, json={"content_link": url},
  185. headers={"Content-Type": "application/json"},
  186. timeout=settings.crawler_timeout)
  187. resp.raise_for_status()
  188. data = resp.json()
  189. except httpx.HTTPError as exc:
  190. raise SearchError(f"http_error: {exc}") from exc
  191. except ValueError as exc:
  192. raise SearchError("bad_json") from exc
  193. if data.get("code") not in (0, "0"):
  194. raise SearchError(f"business_error: code={data.get('code')} msg={data.get('msg')}")
  195. inner = ((data.get("data") or {}).get("data")) or {}
  196. imgs = []
  197. for im in inner.get("image_url_list") or []:
  198. u = im.get("image_url") if isinstance(im, dict) else im
  199. if u:
  200. imgs.append(u)
  201. return inner.get("body_text") or "", imgs
  202. finally:
  203. if owns_client:
  204. client.close()
  205. # ---------- 小红书搜索(封面/标题/作者已在搜索回包的 note_card 里,无需 detail)----------
  206. def parse_xiaohongshu(response: Any, *, limit: int = 5) -> list[dict]:
  207. """小红书搜索回包 {code,data:{data:[{id, note_card:{image_list,display_title,desc,user,type}}]}}
  208. → 帖子列表,直接取封面(image_list[0].image_url)+标题+作者,省去逐条 detail。无视频直链。"""
  209. if not isinstance(response, dict):
  210. raise SearchError("bad_response: not a dict")
  211. if response.get("code") not in (0, "0"):
  212. raise SearchError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
  213. items = ((response.get("data") or {}).get("data")) or []
  214. out: list[dict] = []
  215. for it in items:
  216. if not isinstance(it, dict):
  217. continue
  218. cid = it.get("id")
  219. nc = it.get("note_card") or {}
  220. img_list = nc.get("image_list") or []
  221. cover = (img_list[0] or {}).get("image_url") if img_list else ""
  222. if not cid or not cover:
  223. continue
  224. title = (nc.get("display_title") or "").strip()
  225. if not title:
  226. title = (nc.get("desc") or "").strip().split("\n")[0][:50]
  227. out.append({"id": cid, "title": title, "cover_url": cover,
  228. "nick_name": (nc.get("user") or {}).get("nickname") or "",
  229. "type": nc.get("type") or "",
  230. "url": f"https://www.xiaohongshu.com/explore/{cid}"})
  231. if len(out) >= limit:
  232. break
  233. return out
  234. def search_xiaohongshu(
  235. keyword: str,
  236. *,
  237. content_type: str = "图文",
  238. limit: int = 5,
  239. settings: Optional[Settings] = None,
  240. http_client: Any = None,
  241. rate_limiter: Optional[RateLimiter] = None,
  242. env_file: str = ".env",
  243. ) -> list[dict]:
  244. """小红书搜索:query → 帖子列表(带 url/封面/标题,无需 detail;视频帖无直链)。失败抛 SearchError。"""
  245. settings = settings or Settings.from_env(env_file)
  246. rate_limiter = rate_limiter or RateLimiter()
  247. owns_client = http_client is None
  248. client = http_client or httpx.Client()
  249. try:
  250. rate_limiter.wait("xiaohongshu_keyword")
  251. url = urljoin(settings.aiddit_crawler_base_url, "/crawler/xiao_hong_shu/keyword")
  252. try:
  253. resp = client.post(url, json={"keyword": keyword, "content_type": content_type,
  254. "sort_type": "综合", "publish_time": "", "cursor": ""},
  255. headers={"Content-Type": "application/json"},
  256. timeout=settings.crawler_timeout)
  257. resp.raise_for_status()
  258. data = resp.json()
  259. except httpx.HTTPError as exc:
  260. raise SearchError(f"http_error: {exc}") from exc
  261. except ValueError as exc:
  262. raise SearchError("bad_json") from exc
  263. return parse_xiaohongshu(data, limit=limit)
  264. finally:
  265. if owns_client:
  266. client.close()