crawler.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. """帖子详情拉取:多平台 detail 接口(小红书/抖音/快手/B站)。
  2. crawler.aiddit.com 各平台 detail 返回字段已归一化(channel_content_id/title/content_type/
  3. body_text/image_url_list/video_url_list/channel_account_*),所以 parse 一套复用;差异只在
  4. detail path、URL→content_id 解析、平台名/id 前缀。详见 数据接口与来源/视频音频取数实测.md。
  5. 拆成两层:parse_detail_response 纯函数(可离线用 fixture 测)+ fetch_post_detail 负责 HTTP。
  6. """
  7. from __future__ import annotations
  8. import re
  9. import time
  10. from typing import Any, Callable, Optional
  11. from urllib.parse import urljoin
  12. import httpx
  13. from core.config import Settings
  14. from core.models import Card, Post
  15. RATE_LIMIT_SECONDS = 15.0
  16. # 平台路由:detail path + Post.id 前缀
  17. PLATFORMS = {
  18. "xiaohongshu": {"path": "/crawler/xiao_hong_shu/detail", "prefix": "xhs"},
  19. "douyin": {"path": "/crawler/dou_yin/detail", "prefix": "dy"},
  20. "kuaishou": {"path": "/crawler/kuai_shou/detail", "prefix": "ks"},
  21. "bilibili": {"path": "/crawler/bilibili/detail", "prefix": "bili"},
  22. }
  23. def _last_seg(s: str) -> str:
  24. return s.split("?", 1)[0].rstrip("/").split("/")[-1]
  25. def detect_platform_and_id(content_id_or_url: str) -> tuple[str, str]:
  26. """从 URL(或裸 id)识别平台 + content_id。不支持短链(v.douyin.com 等需先跟随重定向)。"""
  27. s = content_id_or_url.strip()
  28. low = s.lower()
  29. if "xiaohongshu.com" in low or "/explore/" in low or "/discovery/item/" in low:
  30. return "xiaohongshu", parse_content_id(s)
  31. if "douyin.com" in low:
  32. m = (re.search(r"modal_id=(\d+)", s) or re.search(r"/video/(\d+)", s)
  33. or re.search(r"/note/(\d+)", s) or re.search(r"(\d{15,})", s))
  34. return "douyin", m.group(1) if m else _last_seg(s)
  35. if "kuaishou.com" in low or "gifshow.com" in low:
  36. m = re.search(r"/(?:fw/photo|short-video|photo|f)/([A-Za-z0-9_-]+)", s)
  37. return "kuaishou", m.group(1) if m else _last_seg(s)
  38. if "bilibili.com" in low or "b23.tv" in low:
  39. m = re.search(r"(BV[0-9A-Za-z]+)", s)
  40. return "bilibili", m.group(1) if m else _last_seg(s)
  41. # 裸 id 兜底:BV→B站;纯长数字→抖音;其余→小红书(向后兼容)
  42. if s.startswith("BV"):
  43. return "bilibili", s
  44. if re.fullmatch(r"\d{15,}", s):
  45. return "douyin", s
  46. return "xiaohongshu", parse_content_id(s)
  47. class CrawlerError(RuntimeError):
  48. pass
  49. class RateLimiter:
  50. """同一 bucket 两次调用间隔 ≥ min_interval。对齐 CFA RateLimiter。"""
  51. def __init__(
  52. self,
  53. min_interval_seconds: float = RATE_LIMIT_SECONDS,
  54. now_fn: Callable[[], float] = time.monotonic,
  55. sleep_fn: Callable[[float], None] = time.sleep,
  56. ) -> None:
  57. self.min_interval_seconds = min_interval_seconds
  58. self.now_fn = now_fn
  59. self.sleep_fn = sleep_fn
  60. self._last: dict[str, float] = {}
  61. def wait(self, bucket: str) -> None:
  62. last = self._last.get(bucket)
  63. if last is not None:
  64. remaining = self.min_interval_seconds - (self.now_fn() - last)
  65. if remaining > 0:
  66. self.sleep_fn(remaining)
  67. self._last[bucket] = self.now_fn()
  68. def parse_content_id(content_id_or_url: str) -> str:
  69. """接受裸 content_id 或 https://www.xiaohongshu.com/explore/<id>?... 链接。"""
  70. s = content_id_or_url.strip()
  71. if "/explore/" in s:
  72. s = s.split("/explore/", 1)[1]
  73. if "/discovery/item/" in content_id_or_url:
  74. s = content_id_or_url.split("/discovery/item/", 1)[1]
  75. return s.split("?", 1)[0].strip("/").strip()
  76. def _image_urls(inner: dict) -> list[str]:
  77. out: list[str] = []
  78. seen: set[str] = set()
  79. for x in inner.get("image_url_list") or []:
  80. u = x.get("image_url") if isinstance(x, dict) else x
  81. if u and u not in seen:
  82. seen.add(u)
  83. out.append(u)
  84. return out
  85. def _video_urls(inner: dict) -> list[str]:
  86. out: list[str] = []
  87. for x in inner.get("video_url_list") or []:
  88. u = x.get("video_url") if isinstance(x, dict) else x
  89. if u:
  90. out.append(u)
  91. return out
  92. def parse_detail_response(response: dict, *, platform: str = "xiaohongshu",
  93. fallback_content_id: str = "") -> Post:
  94. """把 detail 接口返回(信封 {code,msg,data:{data:{...}}})解析成 Post。字段各平台归一。"""
  95. if not isinstance(response, dict):
  96. raise CrawlerError("bad_response: not a dict")
  97. code = response.get("code")
  98. if code not in (0, "0"):
  99. raise CrawlerError(f"business_error: code={code} msg={response.get('msg')}")
  100. inner = ((response.get("data") or {}).get("data")) or {}
  101. if not inner:
  102. raise CrawlerError("empty_detail: data.data is empty")
  103. prefix = PLATFORMS.get(platform, PLATFORMS["xiaohongshu"])["prefix"]
  104. content_id = inner.get("channel_content_id") or fallback_content_id
  105. link = inner.get("content_link") or ""
  106. images = _image_urls(inner)
  107. # 图文帖:每张图一张卡片(1-based);视频帖的段卡由 extract_video 在提取时写入,覆盖封面卡
  108. cards = [Card(index=i, kind="image", url=u) for i, u in enumerate(images, start=1)]
  109. return Post(
  110. id=f"{prefix}_{content_id}",
  111. platform=platform,
  112. url=link,
  113. content_id=content_id,
  114. title=inner.get("title") or "",
  115. content_type=inner.get("content_type") or "",
  116. body_text=inner.get("body_text") or "",
  117. topic_list=list(inner.get("topic_list") or []),
  118. image_urls=images,
  119. video_urls=_video_urls(inner),
  120. cards=cards,
  121. author_id=inner.get("channel_account_id"),
  122. author_name=inner.get("channel_account_name"),
  123. raw=response,
  124. )
  125. def fetch_post_detail(
  126. content_id_or_url: str,
  127. *,
  128. settings: Optional[Settings] = None,
  129. http_client: Any = None,
  130. rate_limiter: Optional[RateLimiter] = None,
  131. env_file: str = ".env",
  132. ) -> Post:
  133. """真实拉取一条帖子详情,自动识别平台(小红书/抖音/快手/B站),返回 Post。"""
  134. settings = settings or Settings.from_env(env_file)
  135. platform, content_id = detect_platform_and_id(content_id_or_url)
  136. if not content_id:
  137. raise CrawlerError(f"cannot parse content_id from: {content_id_or_url}")
  138. cfg = PLATFORMS[platform]
  139. rate_limiter = rate_limiter or RateLimiter()
  140. rate_limiter.wait(f"{platform}_detail")
  141. owns_client = http_client is None
  142. client = http_client or httpx.Client()
  143. try:
  144. url = urljoin(settings.crawler_base_url, cfg["path"])
  145. resp = client.post(
  146. url,
  147. json={"content_id": content_id},
  148. headers={"Content-Type": "application/json"},
  149. timeout=settings.crawler_timeout,
  150. )
  151. resp.raise_for_status()
  152. data = resp.json()
  153. except httpx.HTTPError as exc:
  154. raise CrawlerError(f"http_error: {exc}") from exc
  155. except ValueError as exc:
  156. raise CrawlerError("bad_json") from exc
  157. finally:
  158. if owns_client:
  159. client.close()
  160. return parse_detail_response(data, platform=platform, fallback_content_id=content_id)