suggest.py 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. """联想扩展:种子词 → 小红书 keyword_v2(返回相关帖)→ 从帖子标题/话题标签挖候选搜索词。
  2. ⚠️ 小红书没有纯「搜索联想词」接口,所以这里是退一步:拿种子打 keyword_v2,从返回的
  3. 【相关帖子】的 title + body_text 里的 #标签 + topic_list 挖候选词,不是搜索框下拉补全。
  4. (抖音只有 keyword 搜索接口、且与搜索共享强限流,已从 demo 移除,只保留小红书。)
  5. parse_suggest 纯函数可离线测;suggest 负责 HTTP(mirror search.py)。
  6. """
  7. from __future__ import annotations
  8. import re
  9. from typing import Any, Optional
  10. from urllib.parse import urljoin
  11. import httpx
  12. from acquisition.crawler import RateLimiter
  13. from core.config import Settings
  14. SUGGEST_PATH = "/crawler/xiao_hong_shu/keyword_v2"
  15. _TAG = re.compile(r"#([^\s##]{2,20})")
  16. class SuggestError(RuntimeError):
  17. pass
  18. def parse_suggest(response: Any, *, limit: int = 15) -> list[str]:
  19. """从 keyword_v2 回包的相关帖挖候选搜索词:#标签/话题优先,标题兜底。去重截断。"""
  20. if not isinstance(response, dict):
  21. raise SuggestError("bad_response: not a dict")
  22. if response.get("code") not in (0, "0"):
  23. raise SuggestError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
  24. posts = ((response.get("data") or {}).get("data")) or []
  25. tags: list[str] = []
  26. titles: list[str] = []
  27. for p in posts:
  28. if not isinstance(p, dict):
  29. continue
  30. tags += _TAG.findall(f"{p.get('title', '')} {p.get('body_text', '')}")
  31. for t in (p.get("topic_list") or []):
  32. name = t.get("name") if isinstance(t, dict) else t
  33. if name:
  34. tags.append(str(name))
  35. title = (p.get("title") or "").strip()
  36. if title:
  37. titles.append(title)
  38. out: list[str] = []
  39. seen: set[str] = set()
  40. for c in tags + titles: # 标签优先、标题兜底
  41. c = c.strip()
  42. if c and c not in seen:
  43. seen.add(c)
  44. out.append(c)
  45. if len(out) >= limit:
  46. break
  47. return out
  48. def suggest(
  49. keyword: str,
  50. *,
  51. content_type: str = "图文",
  52. settings: Optional[Settings] = None,
  53. http_client: Any = None,
  54. rate_limiter: Optional[RateLimiter] = None,
  55. env_file: str = ".env",
  56. limit: int = 15,
  57. ) -> list[str]:
  58. """种子词 → 小红书候选搜索词列表(从相关帖挖)。失败抛 SuggestError。"""
  59. settings = settings or Settings.from_env(env_file)
  60. rate_limiter = rate_limiter or RateLimiter()
  61. owns_client = http_client is None
  62. client = http_client or httpx.Client()
  63. try:
  64. rate_limiter.wait("xhs_suggest")
  65. url = urljoin(settings.aiddit_crawler_base_url, SUGGEST_PATH)
  66. body = {"keyword": keyword, "content_type": content_type, "sort_type": "综合", "cursor": ""}
  67. try:
  68. resp = client.post(url, json=body, 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 SuggestError(f"http_error: {exc}") from exc
  74. except ValueError as exc:
  75. raise SuggestError("bad_json") from exc
  76. return parse_suggest(data, limit=limit)
  77. finally:
  78. if owns_client:
  79. client.close()