crawler.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """帖子详情拉取:小红书 detail 接口。
  2. POST {base}/crawler/xiao_hong_shu/detail body={"content_id": ...} 无 token,限流 ≥15s。
  3. 逻辑(限流 + code==0 信封校验)对齐 ContentFindAgentNew 的 crawapi_http.post_crawapi_json,
  4. 但不依赖其错误体系,保持本项目自包含。
  5. 拆成两层:parse_detail_response 纯函数(可离线用 fixture 测)+ fetch_post_detail 负责 HTTP。
  6. """
  7. from __future__ import annotations
  8. import time
  9. from typing import Any, Callable, Optional
  10. from urllib.parse import urljoin
  11. import httpx
  12. from creation_knowledge.config import Settings
  13. from creation_knowledge.models import Card, Post
  14. DETAIL_PATH = "/crawler/xiao_hong_shu/detail"
  15. RATE_LIMIT_SECONDS = 15.0
  16. class CrawlerError(RuntimeError):
  17. pass
  18. class RateLimiter:
  19. """同一 bucket 两次调用间隔 ≥ min_interval。对齐 CFA RateLimiter。"""
  20. def __init__(
  21. self,
  22. min_interval_seconds: float = RATE_LIMIT_SECONDS,
  23. now_fn: Callable[[], float] = time.monotonic,
  24. sleep_fn: Callable[[float], None] = time.sleep,
  25. ) -> None:
  26. self.min_interval_seconds = min_interval_seconds
  27. self.now_fn = now_fn
  28. self.sleep_fn = sleep_fn
  29. self._last: dict[str, float] = {}
  30. def wait(self, bucket: str) -> None:
  31. last = self._last.get(bucket)
  32. if last is not None:
  33. remaining = self.min_interval_seconds - (self.now_fn() - last)
  34. if remaining > 0:
  35. self.sleep_fn(remaining)
  36. self._last[bucket] = self.now_fn()
  37. def parse_content_id(content_id_or_url: str) -> str:
  38. """接受裸 content_id 或 https://www.xiaohongshu.com/explore/<id>?... 链接。"""
  39. s = content_id_or_url.strip()
  40. if "/explore/" in s:
  41. s = s.split("/explore/", 1)[1]
  42. if "/discovery/item/" in content_id_or_url:
  43. s = content_id_or_url.split("/discovery/item/", 1)[1]
  44. return s.split("?", 1)[0].strip("/").strip()
  45. def _image_urls(inner: dict) -> list[str]:
  46. out: list[str] = []
  47. seen: set[str] = set()
  48. for x in inner.get("image_url_list") or []:
  49. u = x.get("image_url") if isinstance(x, dict) else x
  50. if u and u not in seen:
  51. seen.add(u)
  52. out.append(u)
  53. return out
  54. def _video_urls(inner: dict) -> list[str]:
  55. out: list[str] = []
  56. for x in inner.get("video_url_list") or []:
  57. u = x.get("video_url") if isinstance(x, dict) else x
  58. if u:
  59. out.append(u)
  60. return out
  61. def parse_detail_response(response: dict, fallback_content_id: str = "") -> Post:
  62. """把 detail 接口返回(信封 {code,msg,data:{data:{...}}})解析成 Post。"""
  63. if not isinstance(response, dict):
  64. raise CrawlerError("bad_response: not a dict")
  65. code = response.get("code")
  66. if code not in (0, "0"):
  67. raise CrawlerError(f"business_error: code={code} msg={response.get('msg')}")
  68. inner = ((response.get("data") or {}).get("data")) or {}
  69. if not inner:
  70. raise CrawlerError("empty_detail: data.data is empty")
  71. content_id = inner.get("channel_content_id") or fallback_content_id
  72. link = inner.get("content_link") or (
  73. f"https://www.xiaohongshu.com/explore/{content_id}" if content_id else ""
  74. )
  75. images = _image_urls(inner)
  76. # 图文帖:每张图就是一张卡片(1-based;视频帧卡片在 pipeline 抽帧后补入)
  77. cards = [Card(index=i, kind="image", url=u) for i, u in enumerate(images, start=1)]
  78. return Post(
  79. id=f"xhs_{content_id}",
  80. platform="xiaohongshu",
  81. url=link,
  82. content_id=content_id,
  83. title=inner.get("title") or "",
  84. content_type=inner.get("content_type") or "",
  85. body_text=inner.get("body_text") or "",
  86. topic_list=list(inner.get("topic_list") or []),
  87. image_urls=images,
  88. video_urls=_video_urls(inner),
  89. cards=cards,
  90. author_id=inner.get("channel_account_id"),
  91. author_name=inner.get("channel_account_name"),
  92. raw=response,
  93. )
  94. def fetch_post_detail(
  95. content_id_or_url: str,
  96. *,
  97. settings: Optional[Settings] = None,
  98. http_client: Any = None,
  99. rate_limiter: Optional[RateLimiter] = None,
  100. env_file: str = ".env",
  101. ) -> Post:
  102. """真实拉取一条小红书帖子详情,返回 Post。"""
  103. settings = settings or Settings.from_env(env_file)
  104. content_id = parse_content_id(content_id_or_url)
  105. if not content_id:
  106. raise CrawlerError(f"cannot parse content_id from: {content_id_or_url}")
  107. rate_limiter = rate_limiter or RateLimiter()
  108. rate_limiter.wait("xhs_detail")
  109. owns_client = http_client is None
  110. client = http_client or httpx.Client()
  111. try:
  112. url = urljoin(settings.crawler_base_url, DETAIL_PATH)
  113. resp = client.post(
  114. url,
  115. json={"content_id": content_id},
  116. headers={"Content-Type": "application/json"},
  117. timeout=settings.crawler_timeout,
  118. )
  119. resp.raise_for_status()
  120. data = resp.json()
  121. except httpx.HTTPError as exc:
  122. raise CrawlerError(f"http_error: {exc}") from exc
  123. except ValueError as exc:
  124. raise CrawlerError("bad_json") from exc
  125. finally:
  126. if owns_client:
  127. client.close()
  128. return parse_detail_response(data, fallback_content_id=content_id)