search.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. """关键词搜索:query → content_id 列表(翻页 + 去重 + 截断)。
  2. 接口(实测 2026-06-26,host = settings.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. from typing import Any, Optional
  16. from urllib.parse import urljoin
  17. import httpx
  18. from creation_knowledge.config import Settings
  19. from creation_knowledge.integrations.crawler import RateLimiter
  20. # 每平台的搜索参数差异(path / 条目 id 字段 / 排序 / 发布时间 / 起始 cursor / account_id)
  21. PLATFORM_SEARCH = {
  22. "xiaohongshu": {
  23. "path": "/crawler/xiao_hong_shu/keyword", "id_key": "id",
  24. "sort_type": "综合", "publish_time": "", "cursor0": "", "account_id": None,
  25. },
  26. "douyin": {
  27. "path": "/crawler/dou_yin/keyword", "id_key": "aweme_id",
  28. "sort_type": "综合排序", "publish_time": "不限", "cursor0": "0",
  29. # 对齐 ContentFindAgentNew CONTENTFIND_DOUYIN_DEFAULT_ACCOUNT_ID;缺它抖音搜索 code 10000
  30. "account_id": "771431222",
  31. },
  32. }
  33. class SearchError(RuntimeError):
  34. pass
  35. def parse_search_response(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[str], bool, str]:
  36. """信封 {code,msg,data:{has_more,next_cursor,data:[...]}} → (content_ids, has_more, next_cursor)。
  37. 条目 id 字段按平台取(小红书 id / 抖音 aweme_id)。code!=0 抛 SearchError。"""
  38. if not isinstance(response, dict):
  39. raise SearchError("bad_response: not a dict")
  40. code = response.get("code")
  41. if code not in (0, "0"):
  42. raise SearchError(f"business_error: code={code} msg={response.get('msg')}")
  43. data = response.get("data") or {}
  44. items = data.get("data") or []
  45. id_key = (PLATFORM_SEARCH.get(platform) or {}).get("id_key", "id")
  46. ids = [it.get(id_key) for it in items if isinstance(it, dict) and it.get(id_key)]
  47. return ids, bool(data.get("has_more")), str(data.get("next_cursor") or "")
  48. def search_keyword(
  49. keyword: str,
  50. *,
  51. platform: str = "xiaohongshu",
  52. content_type: str = "图文",
  53. sort_type: Optional[str] = None, # None → 平台默认(小红书:综合 / 抖音:综合排序)
  54. publish_time: Optional[str] = None, # None → 平台默认(抖音:不限)
  55. account_id: Optional[str] = None, # None → 平台默认(抖音:771431222;小红书无)
  56. limit: int = 5,
  57. settings: Optional[Settings] = None,
  58. http_client: Any = None,
  59. rate_limiter: Optional[RateLimiter] = None,
  60. env_file: str = ".env",
  61. max_pages: int = 10,
  62. ) -> list[str]:
  63. """搜索 → content_id 列表(翻页直到够 limit 或 has_more=False;按 id 去重、截断 limit)。
  64. 平台专属参数(path/sort_type/publish_time/起始cursor/account_id)取自 PLATFORM_SEARCH,
  65. 显式入参可覆盖。复用 crawler.RateLimiter('{platform}_keyword' bucket)。失败抛 SearchError。"""
  66. cfg = PLATFORM_SEARCH.get(platform)
  67. if not cfg:
  68. raise SearchError(f"unsupported platform: {platform}")
  69. settings = settings or Settings.from_env(env_file)
  70. sort_type = cfg["sort_type"] if sort_type is None else sort_type
  71. publish_time = cfg["publish_time"] if publish_time is None else publish_time
  72. account_id = cfg["account_id"] if account_id is None else account_id
  73. rate_limiter = rate_limiter or RateLimiter()
  74. owns_client = http_client is None
  75. client = http_client or httpx.Client()
  76. out: list[str] = []
  77. seen: set[str] = set()
  78. cursor = cfg["cursor0"]
  79. try:
  80. for _ in range(max_pages):
  81. rate_limiter.wait(f"{platform}_keyword")
  82. url = urljoin(settings.crawler_base_url, cfg["path"])
  83. body = {"keyword": keyword, "content_type": content_type,
  84. "sort_type": sort_type, "publish_time": publish_time, "cursor": cursor}
  85. if account_id:
  86. body["account_id"] = account_id # 抖音必带;小红书不带
  87. try:
  88. resp = client.post(url, json=body,
  89. headers={"Content-Type": "application/json"},
  90. timeout=settings.crawler_timeout)
  91. resp.raise_for_status()
  92. data = resp.json()
  93. except httpx.HTTPError as exc:
  94. raise SearchError(f"http_error: {exc}") from exc
  95. except ValueError as exc:
  96. raise SearchError("bad_json") from exc
  97. ids, has_more, next_cursor = parse_search_response(data, platform=platform)
  98. for cid in ids:
  99. if cid not in seen:
  100. seen.add(cid)
  101. out.append(cid)
  102. if len(out) >= limit:
  103. return out
  104. if not has_more or not next_cursor or next_cursor == cursor:
  105. break
  106. cursor = next_cursor
  107. finally:
  108. if owns_client:
  109. client.close()
  110. return out[:limit]