test_shipinhao_client.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. """V3-M1C: 视频号 client search/normalization/retry/blocked 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.shipinhao import (
  7. SHIPINHAO_SEARCH_MAX_INTERVAL_SECONDS,
  8. SHIPINHAO_SEARCH_MIN_INTERVAL_SECONDS,
  9. CrawapiShipinhaoClient,
  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})
  17. return self.responses.pop(0)
  18. def _response(status_code, data):
  19. return httpx.Response(
  20. status_code, json=data, request=httpx.Request("POST", "http://crawler.test/x")
  21. )
  22. def _query():
  23. return {
  24. "search_query_id": "q_001",
  25. "search_query": "彩虹",
  26. "discovery_start_source": "pattern_itemset",
  27. }
  28. def _client(responses):
  29. sleeps: list[float] = []
  30. client = CrawapiShipinhaoClient(
  31. base_url="http://crawler.test",
  32. http_client=FakeHttpClient(responses),
  33. sleep_fn=sleeps.append,
  34. )
  35. return client, sleeps
  36. _SUCCESS = {
  37. "code": 0,
  38. "data": {
  39. "has_more": True,
  40. "next_cursor": 12,
  41. "data": [
  42. {
  43. "channel_content_id": "finderobj_abc",
  44. "title": "圆形彩虹 #彩虹 #见者好运",
  45. "content_type": "video",
  46. "video_url_list": [{"video_url": "https://findermp.video.qq.com/x"}],
  47. "channel_account_id": "acc_123",
  48. "channel_account_name": "掌上巴彦淖尔",
  49. "like_count": 92,
  50. "publish_timestamp": 1780904037000,
  51. }
  52. ],
  53. },
  54. }
  55. _FAIL_25011 = {"code": 25011, "msg": "视频号接口异常: 获取搜索结果失败", "data": None}
  56. def test_shipinhao_search_maps_canonical_fields():
  57. client, _ = _client([_response(200, _SUCCESS)])
  58. result = client.search(_query())[0]
  59. assert result["platform"] == "shipinhao"
  60. assert result["platform_content_id"] == "finderobj_abc"
  61. assert result["platform_author_id"] == "acc_123"
  62. assert result["author_display_name"] == "掌上巴彦淖尔"
  63. assert result["tags"] == ["#彩虹", "#见者好运"]
  64. assert result["play_url"] == "https://findermp.video.qq.com/x"
  65. assert result["statistics"]["digg_count"] == 92
  66. assert result["create_time"] == 1780904037 # ms -> s
  67. assert result["has_more"] is True
  68. assert result["next_cursor"] == "12"
  69. def test_shipinhao_search_falls_back_to_title_research_for_same_content_id():
  70. first = {
  71. "code": 0,
  72. "data": {
  73. "data": [
  74. {
  75. "channel_content_id": "finderobj_need_retry",
  76. "title": "当狗唱dj #心墙",
  77. "content_type": "video",
  78. "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
  79. "channel_account_id": "acc_123",
  80. "channel_account_name": "掌上巴彦淖尔",
  81. }
  82. ]
  83. },
  84. }
  85. second = {
  86. "code": 0,
  87. "data": {
  88. "data": [
  89. {
  90. "channel_content_id": "finderobj_need_retry",
  91. "title": "当狗唱dj #心墙",
  92. "content_type": "video",
  93. "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
  94. "channel_account_id": "acc_123",
  95. "channel_account_name": "掌上巴彦淖尔",
  96. }
  97. ]
  98. },
  99. }
  100. client, _ = _client([_response(200, first), _response(200, second)])
  101. result = client.search(_query())[0]
  102. assert client.http_client.requests[1]["json"] == {"keyword": "当狗唱dj #心墙", "cursor": ""}
  103. assert result["play_url"] == "https://findermp.video.qq.com/video.mp4"
  104. assert result["platform_raw_payload"]["selected_video_url_path"] == "$.research.video_url_list[0].video_url"
  105. assert result["platform_raw_payload"]["shipinhao_research_status"] == "used"
  106. assert "media_failure_reason" not in result
  107. def test_shipinhao_metadata_search_does_not_probe_or_title_research():
  108. probe_calls: list[str] = []
  109. first = {
  110. "code": 0,
  111. "data": {
  112. "data": [
  113. {
  114. "channel_content_id": "finderobj_metadata",
  115. "title": "当狗唱dj #心墙",
  116. "content_type": "video",
  117. "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
  118. }
  119. ]
  120. },
  121. }
  122. client = CrawapiShipinhaoClient(
  123. base_url="http://crawler.test",
  124. http_client=FakeHttpClient([_response(200, first)]),
  125. sleep_fn=lambda seconds: None,
  126. video_url_probe_fn=lambda url, platform: probe_calls.append(url) or {},
  127. )
  128. [result] = client.search_full_page_metadata(_query())
  129. assert len(client.http_client.requests) == 1
  130. assert probe_calls == []
  131. assert result["platform_content_id"] == "finderobj_metadata"
  132. assert result["media_failure_reason"] == "no_valid_play_url"
  133. assert "_platform_search_item" in result
  134. def test_shipinhao_hydrate_search_result_media_uses_title_research():
  135. first = {
  136. "code": 0,
  137. "data": {
  138. "data": [
  139. {
  140. "channel_content_id": "finderobj_hydrate",
  141. "title": "当狗唱dj #心墙",
  142. "content_type": "video",
  143. "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
  144. }
  145. ]
  146. },
  147. }
  148. second = {
  149. "code": 0,
  150. "data": {
  151. "data": [
  152. {
  153. "channel_content_id": "finderobj_hydrate",
  154. "title": "当狗唱dj #心墙",
  155. "content_type": "video",
  156. "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
  157. }
  158. ]
  159. },
  160. }
  161. client, _ = _client([_response(200, first), _response(200, second)])
  162. [metadata] = client.search_full_page_metadata(_query())
  163. hydrated = client.hydrate_search_result_media(metadata, _query())
  164. assert client.http_client.requests[1]["json"] == {"keyword": "当狗唱dj #心墙", "cursor": ""}
  165. assert hydrated["play_url"] == "https://findermp.video.qq.com/video.mp4"
  166. assert hydrated["platform_raw_payload"]["shipinhao_research_status"] == "used"
  167. assert "_platform_search_item" not in hydrated
  168. def test_shipinhao_search_keeps_no_valid_when_title_research_does_not_match():
  169. first = {
  170. "code": 0,
  171. "data": {
  172. "data": [
  173. {
  174. "channel_content_id": "finderobj_need_retry",
  175. "title": "当狗唱dj #心墙",
  176. "content_type": "video",
  177. "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
  178. }
  179. ]
  180. },
  181. }
  182. second = {
  183. "code": 0,
  184. "data": {
  185. "data": [
  186. {
  187. "channel_content_id": "finderobj_other",
  188. "title": "当狗唱dj #心墙",
  189. "content_type": "video",
  190. "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
  191. }
  192. ]
  193. },
  194. }
  195. client, _ = _client([_response(200, first), _response(200, second)])
  196. result = client.search(_query())[0]
  197. assert result["play_url"] is None
  198. assert result["media_failure_reason"] == "no_valid_play_url"
  199. assert result["platform_raw_payload"]["shipinhao_research_status"] == "not_matched"
  200. def test_shipinhao_search_default_limit_is_five():
  201. items = [
  202. {
  203. "channel_content_id": f"finderobj_{index}",
  204. "title": "圆形彩虹",
  205. "content_type": "video",
  206. }
  207. for index in range(6)
  208. ]
  209. success = {"code": 0, "data": {"data": items}}
  210. client, _ = _client([_response(200, success)])
  211. results = client.search(_query())
  212. assert [result["platform_content_id"] for result in results] == [
  213. "finderobj_0",
  214. "finderobj_1",
  215. "finderobj_2",
  216. "finderobj_3",
  217. "finderobj_4",
  218. ]
  219. def test_shipinhao_search_full_page_bypasses_local_limit():
  220. items = [
  221. {
  222. "channel_content_id": f"finderobj_{index}",
  223. "title": "圆形彩虹",
  224. "content_type": "video",
  225. }
  226. for index in range(3)
  227. ]
  228. client, _ = _client([_response(200, {"code": 0, "data": {"data": items}})])
  229. client.max_results_per_query = 1
  230. results = client.search_full_page(_query())
  231. assert [result["platform_content_id"] for result in results] == [
  232. "finderobj_0",
  233. "finderobj_1",
  234. "finderobj_2",
  235. ]
  236. def test_shipinhao_search_retries_on_25011_then_succeeds():
  237. client, sleeps = _client([_response(200, _FAIL_25011), _response(200, _SUCCESS)])
  238. result = client.search(_query())
  239. assert len(result) == 1
  240. assert sleeps == [1] # one backoff before the successful retry
  241. def test_shipinhao_search_does_not_retry_empty_result():
  242. empty = {"code": 0, "data": {"has_more": False, "next_cursor": "", "data": []}}
  243. client, sleeps = _client([_response(200, empty)])
  244. assert client.search(_query()) == []
  245. assert sleeps == []
  246. def test_shipinhao_search_raises_after_exhausted():
  247. client, sleeps = _client([_response(200, _FAIL_25011) for _ in range(3)])
  248. with pytest.raises(ContentAgentError) as exc:
  249. client.search(_query())
  250. assert exc.value.error_code == ErrorCode.PLATFORM_REQUEST_FAILED
  251. assert sleeps == [1, 2] # backoff before attempts 2 and 3
  252. def test_shipinhao_fetch_author_works_blocked_returns_empty():
  253. client, _ = _client([])
  254. assert client.fetch_author_works({"platform_author_id": "acc_123"}) == []
  255. assert client.http_client.requests == []
  256. def test_shipinhao_from_env_reads_limit_override(monkeypatch, tmp_path):
  257. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawler.test")
  258. monkeypatch.setenv("CONTENTFIND_SHIPINHAO_MAX_RESULTS_PER_QUERY", "2")
  259. client = CrawapiShipinhaoClient.from_env(env_path=tmp_path / "missing.env")
  260. assert client.max_results_per_query == 2
  261. def test_shipinhao_from_env_default_limit_is_five(monkeypatch, tmp_path):
  262. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawler.test")
  263. client = CrawapiShipinhaoClient.from_env(env_path=tmp_path / "missing.env")
  264. assert client.max_results_per_query == 5
  265. def test_shipinhao_from_env_search_limiter_is_ten_seconds(monkeypatch, tmp_path):
  266. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawler.test")
  267. client = CrawapiShipinhaoClient.from_env(env_path=tmp_path / "missing.env")
  268. assert client.rate_limiter is not None
  269. assert client.rate_limiter.min_interval_seconds == SHIPINHAO_SEARCH_MIN_INTERVAL_SECONDS
  270. assert client.rate_limiter.max_interval_seconds == SHIPINHAO_SEARCH_MAX_INTERVAL_SECONDS
  271. def test_shipinhao_account_info_is_not_reliable_capability():
  272. client, _ = _client([])
  273. assert not hasattr(client, "fetch_account_info")