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 creation_knowledge.config import Settings
  14. from creation_knowledge.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)