test_platform_adapters.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. from __future__ import annotations
  2. from types import SimpleNamespace
  3. from acquisition.platforms import douyin as douyin_module
  4. from acquisition.platforms import weixin as weixin_module
  5. from acquisition.platforms import xiaohongshu as xhs_module
  6. from acquisition.platforms.base import PlatformCandidate
  7. from acquisition.platforms.douyin import DouyinAdapter
  8. from acquisition.platforms.weixin import WeixinAdapter
  9. from acquisition.platforms.xiaohongshu import XiaohongshuAdapter
  10. from acquisition.search import SearchHit, SearchPage
  11. from core.models import Post
  12. def test_xiaohongshu_adapter_maps_search_and_detail(monkeypatch):
  13. settings = SimpleNamespace(search_content_type="图文")
  14. def fake_search(query, *, cursor, content_type, limit, settings, rate_limiter):
  15. assert query == "脚本 开头"
  16. assert cursor == ""
  17. assert content_type is None
  18. assert limit == 3
  19. return SearchPage(
  20. rows=[
  21. {
  22. "id": "xhs-1",
  23. "url": "https://xhs.test/1",
  24. "title": "脚本开头",
  25. "nick_name": "作者 A",
  26. "cover_url": "https://img.test/cover.jpg",
  27. }
  28. ],
  29. raw_count=1,
  30. cursor=cursor,
  31. )
  32. def fake_fetch(source_id, *, settings, rate_limiter):
  33. assert source_id == "xhs-1"
  34. return Post(
  35. id="xhs_xhs-1",
  36. platform="xiaohongshu",
  37. url="https://xhs.test/detail/1",
  38. content_id="xhs-1",
  39. title="详情标题",
  40. content_type="图文",
  41. body_text="详情正文",
  42. image_urls=["https://img.test/1.jpg"],
  43. author_name="详情作者",
  44. raw={"source": "mock"},
  45. )
  46. monkeypatch.setattr(xhs_module, "search_xiaohongshu_page", fake_search)
  47. monkeypatch.setattr(xhs_module, "fetch_post_detail", fake_fetch)
  48. adapter = XiaohongshuAdapter()
  49. candidate = adapter.search("脚本 开头", settings=settings, limit=3, rate_limiter=None)[0]
  50. item = adapter.fetch_detail(candidate, settings=settings, rate_limiter=None)
  51. assert candidate.source_id == "xhs-1"
  52. assert candidate.author == "作者 A"
  53. assert candidate.raw["page_index"] == 1
  54. assert candidate.raw["page_rank"] == 1
  55. assert item.title == "详情标题"
  56. assert item.image_urls == ["https://img.test/1.jpg"]
  57. assert item.content_mode == "image_post"
  58. assert item.raw["source"] == "mock"
  59. def test_xiaohongshu_adapter_search_pages_uses_next_cursor(monkeypatch):
  60. settings = SimpleNamespace(search_content_type="图文")
  61. cursors: list[str] = []
  62. def fake_search(query, *, cursor, content_type, limit, settings, rate_limiter):
  63. cursors.append(cursor)
  64. if cursor == "":
  65. return SearchPage(
  66. rows=[{"id": "xhs-1", "url": "https://xhs.test/1", "title": "第一页"}],
  67. raw_count=1,
  68. cursor=cursor,
  69. next_cursor="2",
  70. has_more=True,
  71. )
  72. return SearchPage(
  73. rows=[{"id": "xhs-2", "url": "https://xhs.test/2", "title": "第二页"}],
  74. raw_count=1,
  75. cursor=cursor,
  76. has_more=False,
  77. )
  78. monkeypatch.setattr(xhs_module, "search_xiaohongshu_page", fake_search)
  79. pages = list(
  80. XiaohongshuAdapter().search_pages(
  81. "脚本",
  82. settings=settings,
  83. limit=2,
  84. rate_limiter=None,
  85. max_pages=2,
  86. )
  87. )
  88. assert cursors == ["", "2"]
  89. assert [page.candidates[0].source_id for page in pages] == ["xhs-1", "xhs-2"]
  90. assert pages[1].candidates[0].raw["page_index"] == 2
  91. def test_weixin_adapter_hashes_url_and_reads_detail(monkeypatch):
  92. settings = SimpleNamespace()
  93. def fake_search(query, *, cursor, limit, settings, rate_limiter):
  94. assert query == "公众号 选题"
  95. assert cursor == "0"
  96. assert limit == 2
  97. return SearchPage(
  98. rows=[
  99. {
  100. "url": "https://mp.weixin.qq.com/s/abc",
  101. "title": "公众号选题",
  102. "nick_name": "公众号",
  103. "cover_url": "https://img.test/wx.jpg",
  104. }
  105. ],
  106. raw_count=1,
  107. cursor=cursor,
  108. )
  109. def fake_detail(url, *, settings, rate_limiter):
  110. assert url == "https://mp.weixin.qq.com/s/abc"
  111. return "公众号正文", ["https://img.test/wx-1.jpg"]
  112. monkeypatch.setattr(weixin_module, "search_weixin_page", fake_search)
  113. monkeypatch.setattr(weixin_module, "fetch_weixin_detail", fake_detail)
  114. adapter = WeixinAdapter()
  115. candidate = adapter.search("公众号 选题", settings=settings, limit=2, rate_limiter=None)[0]
  116. item = adapter.fetch_detail(candidate, settings=settings, rate_limiter=None)
  117. assert candidate.platform == "weixin"
  118. assert len(candidate.source_id) == 16
  119. assert candidate.raw["page_index"] == 1
  120. assert item.body_text == "公众号正文"
  121. assert item.image_urls == ["https://img.test/wx-1.jpg"]
  122. assert item.content_mode == "article"
  123. def test_weixin_adapter_search_pages_uses_next_cursor(monkeypatch):
  124. settings = SimpleNamespace()
  125. cursors: list[str] = []
  126. def fake_search(query, *, cursor, limit, settings, rate_limiter):
  127. cursors.append(cursor)
  128. if cursor == "0":
  129. return SearchPage(
  130. rows=[{"url": "https://mp.weixin.qq.com/s/a", "title": "第一页"}],
  131. raw_count=1,
  132. cursor=cursor,
  133. next_cursor="1",
  134. has_more=True,
  135. )
  136. return SearchPage(
  137. rows=[{"url": "https://mp.weixin.qq.com/s/b", "title": "第二页"}],
  138. raw_count=1,
  139. cursor=cursor,
  140. has_more=False,
  141. )
  142. monkeypatch.setattr(weixin_module, "search_weixin_page", fake_search)
  143. pages = list(
  144. WeixinAdapter().search_pages(
  145. "公众号",
  146. settings=settings,
  147. limit=2,
  148. rate_limiter=None,
  149. max_pages=2,
  150. )
  151. )
  152. assert cursors == ["0", "1"]
  153. assert len(pages) == 2
  154. assert pages[1].candidates[0].raw["page_index"] == 2
  155. def test_douyin_adapter_maps_video_search_and_detail(monkeypatch):
  156. settings = SimpleNamespace()
  157. def fake_search(query, *, content_type, limit, settings, rate_limiter):
  158. assert query == "短视频 讲法"
  159. assert content_type == "视频"
  160. assert limit == 1
  161. return [
  162. SearchHit(
  163. content_id="dy-1",
  164. provider="piaoquantv",
  165. raw={"aweme_id": "dy-1"},
  166. request_payload={"keyword": query},
  167. )
  168. ]
  169. def fake_fetch(source_id, *, platform, provider, settings, rate_limiter):
  170. assert source_id == "dy-1"
  171. assert platform == "douyin"
  172. assert provider == "piaoquantv"
  173. return Post(
  174. id="dy_dy-1",
  175. platform="douyin",
  176. provider=provider,
  177. url="https://douyin.test/1",
  178. content_id="dy-1",
  179. title="短视频讲法",
  180. content_type="video",
  181. body_text="视频文案",
  182. video_urls=["https://video.test/1.mp4"],
  183. raw={"source": "mock"},
  184. )
  185. monkeypatch.setattr(douyin_module, "search_douyin_hits", fake_search)
  186. monkeypatch.setattr(douyin_module, "fetch_post_detail", fake_fetch)
  187. adapter = DouyinAdapter()
  188. candidate = adapter.search("短视频 讲法", settings=settings, limit=1, rate_limiter=None)[0]
  189. item = adapter.fetch_detail(candidate, settings=settings, rate_limiter=None)
  190. assert candidate.source_id == "dy-1"
  191. assert candidate.provider == "piaoquantv"
  192. assert candidate.raw["search_provider"] == "piaoquantv"
  193. assert candidate.raw["original"] == {"aweme_id": "dy-1"}
  194. assert item.content_type == "video"
  195. assert item.provider == "piaoquantv"
  196. assert item.content_mode == "video_post"
  197. assert item.video_urls == ["https://video.test/1.mp4"]
  198. def test_douyin_adapter_search_pages_preserves_provider_and_page_metadata(monkeypatch):
  199. settings = SimpleNamespace()
  200. cursors_seen: list[dict[str, str]] = []
  201. def fake_search_page(query, *, provider, cursors, content_type, limit, settings, rate_limiter):
  202. cursors_seen.append(dict(cursors))
  203. return SearchPage(
  204. rows=[
  205. SearchHit(
  206. content_id="dy-1",
  207. provider="aiddit",
  208. raw={"aweme_id": "dy-1"},
  209. request_payload={"keyword": query},
  210. )
  211. ],
  212. raw_count=1,
  213. cursor="",
  214. provider="aiddit",
  215. request_payload={"keyword": query},
  216. raw_metadata={"cursors": {"aiddit": "20"}},
  217. )
  218. monkeypatch.setattr(douyin_module, "search_douyin_page", fake_search_page)
  219. page = next(
  220. iter(
  221. DouyinAdapter().search_pages(
  222. "短视频",
  223. settings=settings,
  224. limit=1,
  225. rate_limiter=None,
  226. max_pages=1,
  227. )
  228. )
  229. )
  230. assert cursors_seen == [{}]
  231. assert page.provider == "aiddit"
  232. assert page.candidates[0].provider == "aiddit"
  233. assert page.candidates[0].raw["page_index"] == 1
  234. assert page.candidates[0].raw["search_provider"] == "aiddit"
  235. def test_douyin_adapter_falls_back_to_image_post(monkeypatch):
  236. settings = SimpleNamespace()
  237. def fake_search(query, *, content_type, limit, settings, rate_limiter):
  238. assert content_type == "视频"
  239. return [SearchHit(content_id="dy-img-1", provider="aiddit")]
  240. def fake_fetch(source_id, *, platform, provider, settings, rate_limiter):
  241. assert provider == "aiddit"
  242. return Post(
  243. id="dy_dy-img-1",
  244. platform="douyin",
  245. provider=provider,
  246. url="https://douyin.test/img/1",
  247. content_id="dy-img-1",
  248. title="图片内容",
  249. content_type="image",
  250. body_text="图片正文",
  251. image_urls=["https://img.test/dy.jpg"],
  252. raw={"source": "mock"},
  253. )
  254. monkeypatch.setattr(douyin_module, "search_douyin_hits", fake_search)
  255. monkeypatch.setattr(douyin_module, "fetch_post_detail", fake_fetch)
  256. item = DouyinAdapter().fetch_detail(
  257. DouyinAdapter().search("图片", settings=settings, limit=1, rate_limiter=None)[0],
  258. settings=settings,
  259. rate_limiter=None,
  260. )
  261. assert item.content_mode == "image_post"
  262. assert item.provider == "aiddit"
  263. assert item.image_urls == ["https://img.test/dy.jpg"]
  264. def test_douyin_adapter_requires_provider_for_detail():
  265. try:
  266. DouyinAdapter().fetch_detail(
  267. PlatformCandidate(rank=1, platform="douyin", source_id="dy-1"),
  268. settings=SimpleNamespace(),
  269. rate_limiter=None,
  270. )
  271. except RuntimeError as exc:
  272. assert "missing search provider" in str(exc)
  273. else:
  274. raise AssertionError("missing provider should fail")