search.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  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. 搜索只给预览;全文/全图/视频仍需 crawler.fetch_post_detail 逐 id 取详情。
  7. parse_search_response 纯函数可离线测;search_keyword 负责 HTTP + 翻页。
  8. 抖音 /keyword 实测 code:10000(疑需 cookie/鉴权)→ 抛 SearchError,由调用方跳过。
  9. """
  10. from __future__ import annotations
  11. from typing import Any, Optional
  12. from urllib.parse import urljoin
  13. import httpx
  14. from creation_knowledge.config import Settings
  15. from creation_knowledge.integrations.crawler import RateLimiter
  16. SEARCH_PATHS = {
  17. "xiaohongshu": "/crawler/xiao_hong_shu/keyword",
  18. "douyin": "/crawler/dou_yin/keyword", # TODO: 实测 code 10000,需 cookie/鉴权
  19. }
  20. class SearchError(RuntimeError):
  21. pass
  22. def parse_search_response(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[str], bool, str]:
  23. """信封 {code,msg,data:{has_more,next_cursor,data:[...]}} → (content_ids, has_more, next_cursor)。
  24. code!=0 抛 SearchError(如抖音 10000)。"""
  25. if not isinstance(response, dict):
  26. raise SearchError("bad_response: not a dict")
  27. code = response.get("code")
  28. if code not in (0, "0"):
  29. raise SearchError(f"business_error: code={code} msg={response.get('msg')}")
  30. data = response.get("data") or {}
  31. items = data.get("data") or []
  32. ids = [it.get("id") for it in items if isinstance(it, dict) and it.get("id")]
  33. return ids, bool(data.get("has_more")), str(data.get("next_cursor") or "")
  34. def search_keyword(
  35. keyword: str,
  36. *,
  37. platform: str = "xiaohongshu",
  38. content_type: str = "图文",
  39. sort_type: str = "综合",
  40. limit: int = 5,
  41. publish_time: str = "",
  42. settings: Optional[Settings] = None,
  43. http_client: Any = None,
  44. rate_limiter: Optional[RateLimiter] = None,
  45. env_file: str = ".env",
  46. max_pages: int = 10,
  47. ) -> list[str]:
  48. """搜索 → content_id 列表(翻页直到够 limit 或 has_more=False;按 id 去重、截断 limit)。
  49. 复用 crawler.RateLimiter('{platform}_keyword' bucket)。失败抛 SearchError。"""
  50. settings = settings or Settings.from_env(env_file)
  51. path = SEARCH_PATHS.get(platform)
  52. if not path:
  53. raise SearchError(f"unsupported platform: {platform}")
  54. rate_limiter = rate_limiter or RateLimiter()
  55. owns_client = http_client is None
  56. client = http_client or httpx.Client()
  57. out: list[str] = []
  58. seen: set[str] = set()
  59. cursor = ""
  60. try:
  61. for _ in range(max_pages):
  62. rate_limiter.wait(f"{platform}_keyword")
  63. url = urljoin(settings.crawler_base_url, path)
  64. body = {"keyword": keyword, "content_type": content_type,
  65. "sort_type": sort_type, "publish_time": publish_time, "cursor": cursor}
  66. try:
  67. resp = client.post(url, json=body,
  68. headers={"Content-Type": "application/json"},
  69. timeout=settings.crawler_timeout)
  70. resp.raise_for_status()
  71. data = resp.json()
  72. except httpx.HTTPError as exc:
  73. raise SearchError(f"http_error: {exc}") from exc
  74. except ValueError as exc:
  75. raise SearchError("bad_json") from exc
  76. ids, has_more, next_cursor = parse_search_response(data, platform=platform)
  77. for cid in ids:
  78. if cid not in seen:
  79. seen.add(cid)
  80. out.append(cid)
  81. if len(out) >= limit:
  82. return out
  83. if not has_more or not next_cursor or next_cursor == cursor:
  84. break
  85. cursor = next_cursor
  86. finally:
  87. if owns_client:
  88. client.close()
  89. return out[:limit]