test_kuaishou_client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. """V4-M2: 快手 client search/detail/account_info normalization tests."""
  2. from __future__ import annotations
  3. import httpx
  4. import pytest
  5. from content_agent.errors import ContentAgentError, ErrorCode
  6. from content_agent.integrations.kuaishou import (
  7. KUAISHOU_SEARCH_MAX_INTERVAL_SECONDS,
  8. KUAISHOU_SEARCH_MIN_INTERVAL_SECONDS,
  9. CrawapiKuaishouClient,
  10. )
  11. class FakeHttpClient:
  12. def __init__(self, responses):
  13. self.responses = list(responses)
  14. self.requests = []
  15. def post(self, url, json, headers, timeout):
  16. self.requests.append({"url": url, "json": json, "headers": headers, "timeout": timeout})
  17. response = self.responses.pop(0)
  18. if isinstance(response, Exception):
  19. raise response
  20. return response
  21. def _response(status_code, data):
  22. return httpx.Response(
  23. status_code,
  24. json=data,
  25. request=httpx.Request("POST", "http://crawler.test/x"),
  26. )
  27. def _query():
  28. return {
  29. "search_query_id": "q_001",
  30. "search_query": "早上好",
  31. "search_query_generation_method": "item_single",
  32. "discovery_start_source": "pattern_itemset",
  33. }
  34. def _author_query():
  35. return {
  36. "search_query_id": "author_001",
  37. "platform_author_id": "3xfkwajatdh7p7i",
  38. "discovery_start_source": "pattern_itemset",
  39. }
  40. def _client(responses):
  41. return CrawapiKuaishouClient(
  42. base_url="http://crawler.test",
  43. http_client=FakeHttpClient(responses),
  44. )
  45. def _item(content_id="ks_001"):
  46. return {
  47. "channel_content_id": content_id,
  48. "content_link": f"https://www.kuaishou.com/short-video/{content_id}",
  49. "title": "早上好 #祝福",
  50. "body_text": "早安视频",
  51. "topic_list": ["早上好"],
  52. "content_type": "video",
  53. "video_url_list": [{"video_url": "https://v.kwaicdn.test/a.mp4"}],
  54. "channel_account_id": "3xfkwajatdh7p7i",
  55. "channel_account_name": "祝福账号",
  56. "view_count": 12345,
  57. "like_count": 234,
  58. "collect_count": 12,
  59. "comment_count": 34,
  60. "share_count": 56,
  61. "publish_timestamp": 1780904037000,
  62. }
  63. def test_kuaishou_search_maps_canonical_fields():
  64. client = _client([
  65. _response(200, {"code": 0, "data": {"data": [_item()], "has_more": False}})
  66. ])
  67. result = client.search(_query())[0]
  68. assert client.http_client.requests[0]["url"].endswith("/crawler/kuai_shou/keyword_v2")
  69. assert client.http_client.requests[0]["json"] == {"keyword": "早上好"}
  70. assert result["platform"] == "kuaishou"
  71. assert result["platform_content_id"] == "ks_001"
  72. assert result["platform_content_url"].endswith("/ks_001")
  73. assert result["platform_author_id"] == "3xfkwajatdh7p7i"
  74. assert result["author_display_name"] == "祝福账号"
  75. assert result["play_url"] == "https://v.kwaicdn.test/a.mp4"
  76. assert result["video_url_candidates"][0]["url"] == "https://v.kwaicdn.test/a.mp4"
  77. assert result["video_url_candidates"][0]["source"] == "search"
  78. assert result["statistics"] == {
  79. "digg_count": 234,
  80. "comment_count": 34,
  81. "share_count": 56,
  82. "collect_count": 12,
  83. "play_count": 12345,
  84. }
  85. assert result["tags"] == ["#早上好"]
  86. assert result["create_time"] == 1780904037
  87. assert result["platform_raw_payload"]["channel_content_id"] == "ks_001"
  88. def test_kuaishou_search_falls_back_to_detail_video_url():
  89. search_item = {**_item("ks_detail_fallback"), "video_url_list": []}
  90. detail_item = {**_item("ks_detail_fallback"), "video_url_list": [{"video_url": "https://v.kwaicdn.test/detail.mp4"}]}
  91. client = _client(
  92. [
  93. _response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}}),
  94. _response(200, {"code": 0, "data": {"data": detail_item}}),
  95. ]
  96. )
  97. result = client.search(_query())[0]
  98. assert client.http_client.requests[1]["url"].endswith("/crawler/kuai_shou/detail")
  99. assert client.http_client.requests[1]["json"] == {"content_id": "ks_detail_fallback"}
  100. assert result["play_url"] == "https://v.kwaicdn.test/detail.mp4"
  101. assert result["platform_raw_payload"]["selected_video_url_path"] == "$.detail.video_url_list[0].video_url"
  102. assert result["platform_raw_payload"]["kuaishou_detail_fallback_status"] == "used"
  103. assert result["video_url_candidates"][0]["url"] == "https://v.kwaicdn.test/detail.mp4"
  104. assert "media_failure_reason" not in result
  105. def test_kuaishou_metadata_search_does_not_probe_or_fetch_detail():
  106. probe_calls: list[str] = []
  107. search_item = {**_item("ks_metadata"), "video_url_list": []}
  108. client = CrawapiKuaishouClient(
  109. base_url="http://crawler.test",
  110. http_client=FakeHttpClient(
  111. [_response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}})]
  112. ),
  113. video_url_probe_fn=lambda url, platform: probe_calls.append(url) or {},
  114. )
  115. [result] = client.search_full_page_metadata(_query())
  116. assert len(client.http_client.requests) == 1
  117. assert probe_calls == []
  118. assert result["platform_content_id"] == "ks_metadata"
  119. assert result["media_failure_reason"] == "no_valid_play_url"
  120. assert "_platform_search_item" in result
  121. def test_kuaishou_hydrate_search_result_media_uses_detail_fallback():
  122. search_item = {**_item("ks_hydrate"), "video_url_list": []}
  123. detail_item = {
  124. **_item("ks_hydrate"),
  125. "video_url_list": [{"video_url": "https://v.kwaicdn.test/detail.mp4"}],
  126. }
  127. client = _client(
  128. [
  129. _response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}}),
  130. _response(200, {"code": 0, "data": {"data": detail_item}}),
  131. ]
  132. )
  133. [metadata] = client.search_full_page_metadata(_query())
  134. hydrated = client.hydrate_search_result_media(metadata, _query())
  135. assert client.http_client.requests[1]["url"].endswith("/crawler/kuai_shou/detail")
  136. assert hydrated["play_url"] == "https://v.kwaicdn.test/detail.mp4"
  137. assert hydrated["platform_raw_payload"]["kuaishou_detail_fallback_status"] == "used"
  138. assert hydrated["video_url_candidates"][0]["url"] == "https://v.kwaicdn.test/detail.mp4"
  139. assert "_platform_search_item" not in hydrated
  140. def test_kuaishou_search_keeps_no_valid_after_search_and_detail_fail():
  141. search_item = {**_item("ks_no_video"), "video_url_list": [], "image_url_list": []}
  142. detail_item = {**search_item, "content_link": "https://www.gifshow.com/fw/photo/ks_no_video"}
  143. client = _client(
  144. [
  145. _response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}}),
  146. _response(200, {"code": 0, "data": {"data": detail_item}}),
  147. ]
  148. )
  149. result = client.search(_query())[0]
  150. assert result["play_url"] is None
  151. assert result["media_failure_reason"] == "no_valid_play_url"
  152. assert result["platform_raw_payload"]["kuaishou_detail_fallback_status"] == "no_valid_play_url"
  153. def test_kuaishou_search_limits_to_five_by_default():
  154. items = [_item(f"ks_{index}") for index in range(6)]
  155. client = _client([_response(200, {"code": 0, "data": {"data": items}})])
  156. results = client.search(_query())
  157. assert [result["platform_content_id"] for result in results] == [
  158. "ks_0",
  159. "ks_1",
  160. "ks_2",
  161. "ks_3",
  162. "ks_4",
  163. ]
  164. def test_kuaishou_search_full_page_bypasses_local_limit():
  165. items = [_item(f"ks_{index}") for index in range(3)]
  166. client = _client([_response(200, {"code": 0, "data": {"data": items}})])
  167. client.max_results_per_query = 1
  168. results = client.search_full_page(_query())
  169. assert [result["platform_content_id"] for result in results] == ["ks_0", "ks_1", "ks_2"]
  170. def test_kuaishou_from_env_search_limiter_is_ten_seconds(monkeypatch, tmp_path):
  171. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawler.test")
  172. client = CrawapiKuaishouClient.from_env(env_path=tmp_path / "missing.env")
  173. assert client.rate_limiter is not None
  174. assert client.rate_limiter.min_interval_seconds == KUAISHOU_SEARCH_MIN_INTERVAL_SECONDS
  175. assert client.rate_limiter.max_interval_seconds == KUAISHOU_SEARCH_MAX_INTERVAL_SECONDS
  176. def test_kuaishou_from_env_supports_blogger_path(monkeypatch, tmp_path):
  177. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawler.test")
  178. monkeypatch.setenv("CONTENTFIND_KUAISHOU_BLOGGER_PATH", "/custom/kuaishou/blogger")
  179. client = CrawapiKuaishouClient.from_env(env_path=tmp_path / "missing.env")
  180. assert client.blogger_path == "custom/kuaishou/blogger"
  181. def test_kuaishou_detail_maps_canonical_fields():
  182. detail = _item("ks_detail")
  183. detail["share_count"] = 78
  184. client = _client([_response(200, {"code": 0, "data": {"data": detail}})])
  185. result = client.fetch_detail("ks_detail")
  186. assert client.http_client.requests[0]["url"].endswith("/crawler/kuai_shou/detail")
  187. assert client.http_client.requests[0]["json"] == {"content_id": "ks_detail"}
  188. assert result["platform"] == "kuaishou"
  189. assert result["platform_content_id"] == "ks_detail"
  190. assert result["platform_content_url"].endswith("/ks_detail")
  191. assert result["statistics"]["play_count"] == 12345
  192. assert result["statistics"]["share_count"] == 78
  193. assert result["play_url"] == "https://v.kwaicdn.test/a.mp4"
  194. assert result["create_time"] == 1780904037
  195. def test_kuaishou_account_info_maps_profile_snapshot():
  196. account = {
  197. "channel_account_id": "3xfkwajatdh7p7i",
  198. "ks_id": "ksid_001",
  199. "digit_id": "123456",
  200. "account_link": "https://www.kuaishou.com/profile/3xfkwajatdh7p7i",
  201. "account_name": "祝福账号",
  202. "avatar_url": "https://avatar.test/a.jpg",
  203. "gender": "unknown",
  204. "description": "每天祝福",
  205. "tags": ["北京", "白羊座"],
  206. "follower_count": 1000,
  207. "publish_count": 88,
  208. "like_count": 9000,
  209. "update_timestamp": 1780904037000,
  210. }
  211. client = _client([_response(200, {"code": 0, "data": {"data": account}})])
  212. result = client.fetch_account_info("3xfkwajatdh7p7i", is_cache=False)
  213. assert client.http_client.requests[0]["url"].endswith("/crawler/kuai_shou/account_info")
  214. assert client.http_client.requests[0]["json"] == {
  215. "account_id": "3xfkwajatdh7p7i",
  216. "is_cache": False,
  217. }
  218. assert result["platform_author_id"] == "3xfkwajatdh7p7i"
  219. assert result["author_display_name"] == "祝福账号"
  220. assert result["profile_snapshot"]["tags"] == ["北京", "白羊座"]
  221. assert "tags" not in {key for key in result if key != "profile_snapshot"}
  222. def test_kuaishou_fetch_author_works_maps_canonical_video_items():
  223. client = _client([
  224. _response(200, {"code": 0, "data": {"data": [_item("ks_author_001")], "has_more": True, "next_cursor": "20"}})
  225. ])
  226. result = client.fetch_author_works(_author_query())[0]
  227. assert client.http_client.requests[0]["url"].endswith("/crawler/kuai_shou/blogger")
  228. assert client.http_client.requests[0]["json"] == {"account_id": "3xfkwajatdh7p7i"}
  229. assert result["platform"] == "kuaishou"
  230. assert result["platform_content_id"] == "ks_author_001"
  231. assert result["platform_author_id"] == "3xfkwajatdh7p7i"
  232. assert result["platform_content_format"] == "video"
  233. assert result["play_url"] == "https://v.kwaicdn.test/a.mp4"
  234. assert result["statistics"]["play_count"] == 12345
  235. assert result["has_more"] is True
  236. assert result["next_cursor"] == "20"
  237. assert result["previous_discovery_step"] == "author_works"
  238. assert result["content_metadata_source"] == "kuaishou_blogger"
  239. def test_kuaishou_http_429_maps_to_platform_rate_limited():
  240. client = _client([_response(429, {"error": "too many"})])
  241. with pytest.raises(ContentAgentError) as exc_info:
  242. client.search(_query())
  243. assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  244. assert exc_info.value.detail["status_code"] == 429
  245. def test_kuaishou_bad_json_is_sanitized():
  246. client = CrawapiKuaishouClient(
  247. base_url="http://crawler.test",
  248. http_client=FakeHttpClient(
  249. [
  250. httpx.Response(
  251. 200,
  252. content=b"not json",
  253. request=httpx.Request("POST", "http://crawler.test/x"),
  254. )
  255. ]
  256. ),
  257. )
  258. with pytest.raises(RuntimeError, match="keyword_search failed: bad_json"):
  259. client.search(_query())