crawler.py 7.9 KB

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