test_douyin_client.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. import httpx
  2. import pytest
  3. from content_agent.errors import ContentAgentError, ErrorCode
  4. from content_agent.integrations.douyin import (
  5. DOUYIN_SEARCH_MAX_INTERVAL_SECONDS,
  6. DOUYIN_SEARCH_MIN_INTERVAL_SECONDS,
  7. RAW_AUTHOR_ACCOUNT_KEY,
  8. RAW_AUTHOR_ID_KEY,
  9. RAW_CONTENT_ID_KEY,
  10. CrawapiDouyinClient,
  11. RateLimiter,
  12. )
  13. class FakeHttpClient:
  14. def __init__(self, responses):
  15. self.responses = list(responses)
  16. self.requests = []
  17. def post(self, url, json, headers, timeout):
  18. self.requests.append({"url": url, "json": json, "headers": headers, "timeout": timeout})
  19. response = self.responses.pop(0)
  20. if isinstance(response, Exception):
  21. raise response
  22. return response
  23. def _response(status_code, data):
  24. return httpx.Response(
  25. status_code,
  26. json=data,
  27. request=httpx.Request("POST", "http://crawapi.test/endpoint"),
  28. )
  29. def _client(responses, rate_limiter=None, fallback_base_url="", **overrides):
  30. options = {
  31. "base_url": "http://crawapi.test",
  32. "fallback_base_url": fallback_base_url,
  33. "keyword_path": "/crawler/dou_yin/keyword",
  34. "blogger_path": "/crawler/dou_yin/blogger",
  35. "default_crawapi_account_ref": "771431222",
  36. "http_client": FakeHttpClient(responses),
  37. "rate_limiter": rate_limiter,
  38. }
  39. options.update(overrides)
  40. return CrawapiDouyinClient(**options)
  41. def _search_query(text="早上好祝福视频"):
  42. return {
  43. "search_query_id": "q_001",
  44. "search_query": text,
  45. "discovery_start_source": "pattern_itemset",
  46. }
  47. def test_douyin_keyword_search_maps_content_fields():
  48. client = _client(
  49. [
  50. _response(
  51. 200,
  52. {
  53. "data": {
  54. "data": [
  55. {
  56. RAW_CONTENT_ID_KEY: "7615247738577423622",
  57. "desc": "早睡早起早上好",
  58. "author": {
  59. "nickname": "让你心情变浅的兔子",
  60. RAW_AUTHOR_ID_KEY: "MS4wLjABAAAAdYc",
  61. },
  62. "statistics": {
  63. "digg_count": 1931,
  64. "comment_count": 45,
  65. "share_count": 1968,
  66. "collect_count": 462,
  67. },
  68. "text_extra": [{"hashtag_name": "早上好"}],
  69. }
  70. ],
  71. "has_more": True,
  72. "next_cursor": "10",
  73. }
  74. },
  75. ),
  76. ]
  77. )
  78. results = client.search(_search_query())
  79. assert len(results) == 1
  80. result = results[0]
  81. assert result["content_discovery_id"] == "q_001_content_001"
  82. assert result["platform_content_id"] == "7615247738577423622"
  83. assert result["platform_author_id"] == "MS4wLjABAAAAdYc"
  84. assert result["tags"] == ["#早上好"]
  85. assert result["has_more"] is True
  86. assert result["next_cursor"] == "10"
  87. assert result["discovery_relation"] == "derived_from_pattern_demand"
  88. assert result["platform_auth_mode"] == "no_bearer"
  89. assert result["platform_raw_payload"][RAW_CONTENT_ID_KEY] == "7615247738577423622"
  90. assert client.http_client.requests[0]["json"][RAW_AUTHOR_ACCOUNT_KEY] == "771431222"
  91. # V3 清理: 画像调用链已砍,搜索一条内容只发 1 次 keyword 请求,不再追加画像请求。
  92. assert len(client.http_client.requests) == 1
  93. assert client.http_client.requests[0]["url"].endswith("/crawler/dou_yin/keyword")
  94. def test_douyin_keyword_search_posts_piaoquantv_apifox_payload():
  95. client = _client([_response(200, {"data": {"data": [], "has_more": False}})])
  96. client.search(_search_query("宁艺卓"))
  97. assert client.http_client.requests[0]["json"] == {
  98. RAW_AUTHOR_ACCOUNT_KEY: "771431222",
  99. "keyword": "宁艺卓",
  100. "content_type": "视频",
  101. "sort_type": "综合排序",
  102. "publish_time": "不限",
  103. "duration": "不限",
  104. "cursor": "0",
  105. "cookie_batch": "default",
  106. }
  107. def test_douyin_keyword_search_returns_empty_list():
  108. client = _client([_response(200, {"data": {"data": [], "has_more": False}})])
  109. results = client.search(_search_query("无结果"))
  110. assert results == []
  111. def test_douyin_keyword_search_uses_explicit_page_cursor():
  112. client = _client([_response(200, {"data": {"data": [], "has_more": False}})])
  113. client.search({**_search_query("下一页"), "page_cursor": "20"})
  114. assert client.http_client.requests[0]["json"]["cursor"] == "20"
  115. def test_douyin_fetch_author_works_maps_fake_response():
  116. client = _client(
  117. [
  118. _response(
  119. 200,
  120. {
  121. "data": {
  122. "data": [
  123. {
  124. RAW_CONTENT_ID_KEY: "7615247738577423001",
  125. "desc": "作者作品",
  126. "author": {
  127. "nickname": "作者",
  128. RAW_AUTHOR_ID_KEY: "MS4wLjABAAAA001",
  129. },
  130. "statistics": {"digg_count": 100},
  131. }
  132. ],
  133. "has_more": False,
  134. }
  135. },
  136. ),
  137. ]
  138. )
  139. results = client.fetch_author_works(
  140. {
  141. "search_query_id": "author_001",
  142. "search_query": "作者作品",
  143. "platform_author_id": "MS4wLjABAAAA001",
  144. "discovery_start_source": "pattern_itemset",
  145. }
  146. )
  147. # M5A 受控变化: 作者作品改打 blogger 接口,payload 用 account_id 三字段合同。
  148. assert results[0]["search_query_id"] == "author_001"
  149. assert results[0]["previous_discovery_step"] == "author_works"
  150. assert client.http_client.requests[0]["json"][RAW_AUTHOR_ACCOUNT_KEY] == "MS4wLjABAAAA001"
  151. def test_douyin_non_keyword_requests_keep_aiddit_legacy_base_url():
  152. client = _client(
  153. [
  154. _response(
  155. 200,
  156. {
  157. "data": {
  158. "data": [
  159. {
  160. RAW_CONTENT_ID_KEY: "7615247738577423001",
  161. "desc": "作者作品",
  162. "author": {RAW_AUTHOR_ID_KEY: "MS4wLjABAAAA001"},
  163. }
  164. ],
  165. "has_more": False,
  166. }
  167. },
  168. ),
  169. ],
  170. base_url="http://crawapi.piaoquantv.com",
  171. fallback_base_url="http://crawler.aiddit.com",
  172. )
  173. client.fetch_author_works(
  174. {
  175. "search_query_id": "author_001",
  176. "search_query": "作者作品",
  177. "platform_author_id": "MS4wLjABAAAA001",
  178. "discovery_start_source": "pattern_itemset",
  179. }
  180. )
  181. assert client.http_client.requests[0]["url"].startswith("http://crawler.aiddit.com/")
  182. assert client.http_client.requests[0]["url"].endswith("/crawler/dou_yin/blogger")
  183. assert len(client.http_client.requests) == 1
  184. def test_douyin_keyword_search_http_error_is_sanitized():
  185. client = _client([_response(500, {"error": "server failed"})])
  186. with pytest.raises(RuntimeError, match="keyword_search failed: HTTP 500"):
  187. client.search(_search_query("接口失败"))
  188. def test_douyin_keyword_search_business_error_is_failed():
  189. client = _client([_response(200, {"code": 22001, "msg": "强制登录", "data": None})])
  190. with pytest.raises(RuntimeError, match="keyword_search failed: business_error"):
  191. client.search(_search_query("账号态失败"))
  192. def test_douyin_keyword_search_falls_back_to_candidate_on_unknown_business_error():
  193. client = _client(
  194. [
  195. _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
  196. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  197. ],
  198. fallback_base_url="http://candidate.test",
  199. )
  200. assert client.search(_search_query("主站业务错误")) == []
  201. assert [request["url"] for request in client.http_client.requests] == [
  202. "http://crawapi.test/crawler/dou_yin/keyword",
  203. "http://candidate.test/crawler/dou_yin/keyword",
  204. ]
  205. def test_douyin_keyword_search_falls_back_to_crawler_payload_on_force_login():
  206. client = _client(
  207. [
  208. _response(200, {"code": 22001, "msg": "抖音搜索异常: 强制登录", "data": None}),
  209. _response(
  210. 200,
  211. {
  212. "code": 0,
  213. "data": {
  214. "data": [
  215. {
  216. RAW_CONTENT_ID_KEY: "7615247738577423622",
  217. "desc": "国风传统",
  218. "author": {
  219. "nickname": "作者",
  220. RAW_AUTHOR_ID_KEY: "MS4wLjABAAAAdYc",
  221. },
  222. "statistics": {
  223. "digg_count": 10,
  224. "comment_count": 2,
  225. "share_count": 3,
  226. "collect_count": 4,
  227. },
  228. }
  229. ],
  230. "has_more": True,
  231. "next_cursor": "12",
  232. },
  233. },
  234. ),
  235. ],
  236. fallback_base_url="http://crawler.aiddit.com",
  237. )
  238. results = client.search(_search_query("国风传统"))
  239. assert client.http_client.requests[0]["json"] == {
  240. RAW_AUTHOR_ACCOUNT_KEY: "771431222",
  241. "keyword": "国风传统",
  242. "content_type": "视频",
  243. "sort_type": "综合排序",
  244. "publish_time": "不限",
  245. "duration": "不限",
  246. "cursor": "0",
  247. "cookie_batch": "default",
  248. }
  249. assert client.http_client.requests[1]["json"] == {
  250. "content_type": "视频",
  251. "keyword": "国风传统",
  252. "cursor": "",
  253. "sort_type": "最多点赞",
  254. }
  255. assert results[0]["platform_content_id"] == "7615247738577423622"
  256. assert results[0]["platform_author_id"] == "MS4wLjABAAAAdYc"
  257. assert results[0]["statistics"]["digg_count"] == 10
  258. assert results[0]["has_more"] is True
  259. assert results[0]["next_cursor"] == "12"
  260. def test_douyin_keyword_search_does_not_fallback_on_parameter_error():
  261. client = _client(
  262. [
  263. _response(200, {"code": 10001, "msg": "参数异常: 参数校验不通过", "data": None}),
  264. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  265. ],
  266. fallback_base_url="http://candidate.test",
  267. )
  268. with pytest.raises(RuntimeError, match="business_error code=10001"):
  269. client.search(_search_query("参数错误"))
  270. assert len(client.http_client.requests) == 1
  271. def test_douyin_keyword_search_falls_back_to_candidate_on_network_error():
  272. client = _client(
  273. [
  274. httpx.ConnectError("network failed"),
  275. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  276. ],
  277. fallback_base_url="http://candidate.test",
  278. )
  279. assert client.search(_search_query("网络失败候选")) == []
  280. assert [request["url"] for request in client.http_client.requests] == [
  281. "http://crawapi.test/crawler/dou_yin/keyword",
  282. "http://candidate.test/crawler/dou_yin/keyword",
  283. ]
  284. def test_douyin_keyword_search_candidate_uses_same_rate_limit_bucket():
  285. limiter = FakeRateLimiter()
  286. client = _client(
  287. [
  288. _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
  289. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  290. ],
  291. rate_limiter=limiter,
  292. fallback_base_url="http://candidate.test",
  293. )
  294. client.search(_search_query("候选限流"))
  295. assert limiter.buckets == ["douyin_search", "douyin_search"]
  296. def test_douyin_keyword_search_keeps_host_attempts_when_candidate_fails():
  297. client = _client(
  298. [
  299. _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
  300. _response(500, {"error": "server failed"}),
  301. ],
  302. fallback_base_url="http://candidate.test",
  303. )
  304. with pytest.raises(RuntimeError, match="HTTP 500") as exc_info:
  305. client.search(_search_query("主备都失败"))
  306. attempts = exc_info.value.detail["crawapi_host_attempts"]
  307. assert [attempt["role"] for attempt in attempts] == ["primary", "candidate"]
  308. assert [attempt["provider"] for attempt in attempts] == ["piaoquantv", "crawler"]
  309. assert attempts[0]["business_code"] == "10000"
  310. assert attempts[0]["request_payload_summary"]["duration"] == "不限"
  311. assert attempts[0]["request_payload_summary"]["cookie_batch"] == "default"
  312. assert attempts[1]["status_code"] == 500
  313. def test_douyin_keyword_search_network_error_is_sanitized():
  314. client = _client([httpx.ConnectError("network failed")])
  315. with pytest.raises(RuntimeError, match="keyword_search failed: network_error"):
  316. client.search(_search_query("网络失败"))
  317. def test_douyin_keyword_search_bad_json_is_sanitized():
  318. client = CrawapiDouyinClient(
  319. base_url="http://crawapi.test",
  320. keyword_path="/crawler/dou_yin/keyword",
  321. http_client=FakeHttpClient(
  322. [
  323. httpx.Response(
  324. 200,
  325. content=b"not json",
  326. request=httpx.Request("POST", "http://crawapi.test/endpoint"),
  327. )
  328. ]
  329. ),
  330. )
  331. with pytest.raises(RuntimeError, match="keyword_search failed: bad_json"):
  332. client.search(_search_query("坏 JSON"))
  333. def test_douyin_keyword_search_can_limit_results_per_query():
  334. client = CrawapiDouyinClient(
  335. base_url="http://crawapi.test",
  336. keyword_path="/crawler/dou_yin/keyword",
  337. max_results_per_query=1,
  338. http_client=FakeHttpClient(
  339. [
  340. _response(
  341. 200,
  342. {
  343. "data": {
  344. "data": [
  345. {RAW_CONTENT_ID_KEY: "1", "author": {}, "statistics": {}},
  346. {RAW_CONTENT_ID_KEY: "2", "author": {}, "statistics": {}},
  347. ]
  348. }
  349. },
  350. ),
  351. ]
  352. ),
  353. )
  354. results = client.search(_search_query("限量"))
  355. assert [result["platform_content_id"] for result in results] == ["1"]
  356. assert len(client.http_client.requests) == 1
  357. def test_douyin_keyword_search_default_limit_is_five():
  358. client = CrawapiDouyinClient(
  359. base_url="http://crawapi.test",
  360. keyword_path="/crawler/dou_yin/keyword",
  361. http_client=FakeHttpClient(
  362. [
  363. _response(
  364. 200,
  365. {
  366. "data": {
  367. "data": [
  368. {RAW_CONTENT_ID_KEY: str(index), "author": {}, "statistics": {}}
  369. for index in range(6)
  370. ]
  371. }
  372. },
  373. ),
  374. ]
  375. ),
  376. )
  377. results = client.search(_search_query("默认限量"))
  378. assert [result["platform_content_id"] for result in results] == ["0", "1", "2", "3", "4"]
  379. def test_douyin_keyword_search_full_page_bypasses_local_limit():
  380. client = CrawapiDouyinClient(
  381. base_url="http://crawapi.test",
  382. keyword_path="/crawler/dou_yin/keyword",
  383. max_results_per_query=1,
  384. http_client=FakeHttpClient(
  385. [
  386. _response(
  387. 200,
  388. {
  389. "data": {
  390. "data": [
  391. {RAW_CONTENT_ID_KEY: str(index), "author": {}, "statistics": {}}
  392. for index in range(3)
  393. ]
  394. }
  395. },
  396. ),
  397. ]
  398. ),
  399. )
  400. results = client.search_full_page(_search_query("整页"))
  401. assert [result["platform_content_id"] for result in results] == ["0", "1", "2"]
  402. def _author_query(author_id="MS4wLjABAAAA001", **extra):
  403. return {
  404. "search_query_id": "author_001",
  405. "search_query": "作者作品",
  406. "platform_author_id": author_id,
  407. "discovery_start_source": "pattern_itemset",
  408. **extra,
  409. }
  410. def _blogger_response(items=None, has_more=True, next_cursor="20"):
  411. return _response(
  412. 200,
  413. {
  414. "code": 0,
  415. "data": {"data": items or [], "has_more": has_more, "next_cursor": next_cursor},
  416. },
  417. )
  418. class FakeRateLimiter:
  419. def __init__(self):
  420. self.buckets = []
  421. def wait(self, bucket):
  422. self.buckets.append(bucket)
  423. def test_fetch_author_works_posts_to_blogger_path():
  424. client = _client([_blogger_response()])
  425. client.fetch_author_works(_author_query())
  426. assert client.http_client.requests[0]["url"].endswith("/crawler/dou_yin/blogger")
  427. def test_fetch_author_works_payload_uses_account_id_from_platform_author_id():
  428. client = _client([_blogger_response()])
  429. client.fetch_author_works(_author_query("MS4wLjABAAAA999"))
  430. payload = client.http_client.requests[0]["json"]
  431. assert payload == {
  432. RAW_AUTHOR_ACCOUNT_KEY: "MS4wLjABAAAA999",
  433. "sort_type": "最新",
  434. "cursor": "",
  435. }
  436. def test_fetch_author_works_uses_page_cursor():
  437. client = _client([_blogger_response()])
  438. client.fetch_author_works(_author_query(page_cursor="20"))
  439. assert client.http_client.requests[0]["json"]["cursor"] == "20"
  440. def test_fetch_author_works_normalizes_author_work_fields():
  441. client = _client(
  442. [
  443. _blogger_response(
  444. items=[
  445. {
  446. RAW_CONTENT_ID_KEY: "7615247738577423001",
  447. "desc": "作者作品",
  448. "author": {"nickname": "作者", RAW_AUTHOR_ID_KEY: "MS4wLjABAAAA001"},
  449. "statistics": {"digg_count": 100},
  450. "create_time": 1733000000,
  451. }
  452. ]
  453. ),
  454. ]
  455. )
  456. results = client.fetch_author_works(_author_query())
  457. assert results[0]["platform_content_id"] == "7615247738577423001"
  458. assert results[0]["platform_author_id"] == "MS4wLjABAAAA001"
  459. assert results[0]["statistics"]["digg_count"] == 100
  460. assert results[0]["create_time"] == 1733000000
  461. assert results[0]["previous_discovery_step"] == "author_works"
  462. assert results[0]["content_metadata_source"] == "douyin_blogger"
  463. assert len(client.http_client.requests) == 1
  464. def test_from_env_reads_blogger_path_and_sort_type(monkeypatch, tmp_path):
  465. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawapi.test")
  466. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_FALLBACK_BASE_URL", "http://candidate.test")
  467. monkeypatch.setenv("CONTENTFIND_DOUYIN_KEYWORD_PATH", "/crawler/dou_yin/keyword")
  468. monkeypatch.setenv("CONTENTFIND_DOUYIN_BLOGGER_PATH", "/crawler/dou_yin/blogger")
  469. monkeypatch.setenv("CONTENTFIND_DOUYIN_ACCOUNT_WORKS_DEFAULT_SORT_TYPE", "最热")
  470. monkeypatch.setenv("CONTENTFIND_DOUYIN_PIAOQUANTV_ACCOUNT_ID", "7450041106378522636")
  471. monkeypatch.setenv("CONTENTFIND_DOUYIN_PIAOQUANTV_DURATION", "不限")
  472. monkeypatch.setenv("CONTENTFIND_DOUYIN_PIAOQUANTV_COOKIE_BATCH", "default")
  473. monkeypatch.setenv("CONTENTFIND_DOUYIN_CRAWLER_SORT_TYPE", "最多点赞")
  474. monkeypatch.setenv("CONTENTFIND_DOUYIN_CRAWLER_CURSOR_DEFAULT", "")
  475. client = CrawapiDouyinClient.from_env(env_path=tmp_path / "missing.env")
  476. assert client.base_url == "http://crawapi.test/"
  477. assert client.fallback_base_url == "http://candidate.test/"
  478. assert client.blogger_path == "crawler/dou_yin/blogger"
  479. assert client.piaoquantv_account_id == "7450041106378522636"
  480. assert client.piaoquantv_duration == "不限"
  481. assert client.piaoquantv_cookie_batch == "default"
  482. assert client.crawler_sort_type == "最多点赞"
  483. assert client.crawler_cursor_default == ""
  484. assert client.default_account_works_sort_type == "最热"
  485. assert client.max_results_per_query == 5
  486. assert isinstance(client.rate_limiter, RateLimiter)
  487. assert client.rate_limiter.min_interval_seconds == DOUYIN_SEARCH_MIN_INTERVAL_SECONDS
  488. assert client.rate_limiter.max_interval_seconds == DOUYIN_SEARCH_MAX_INTERVAL_SECONDS
  489. def test_from_env_reads_max_results_override(monkeypatch, tmp_path):
  490. monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawapi.test")
  491. monkeypatch.setenv("CONTENTFIND_DOUYIN_KEYWORD_PATH", "/crawler/dou_yin/keyword")
  492. monkeypatch.setenv("CONTENTFIND_DOUYIN_BLOGGER_PATH", "/crawler/dou_yin/blogger")
  493. monkeypatch.setenv("CONTENTFIND_DOUYIN_MAX_RESULTS_PER_QUERY", "2")
  494. client = CrawapiDouyinClient.from_env(env_path=tmp_path / "missing.env")
  495. assert client.max_results_per_query == 2
  496. def test_rate_limiter_waits_between_keyword_calls():
  497. clock = {"now": 0.0}
  498. sleeps = []
  499. def fake_sleep(seconds):
  500. sleeps.append(seconds)
  501. clock["now"] += seconds
  502. limiter = RateLimiter(min_interval_seconds=30.0, now_fn=lambda: clock["now"], sleep_fn=fake_sleep)
  503. limiter.wait("douyin_search")
  504. limiter.wait("douyin_search")
  505. assert sleeps == [30.0]
  506. def test_douyin_rate_limiter_supports_ten_to_twelve_second_jitter():
  507. clock = {"now": 0.0}
  508. sleeps = []
  509. def fake_sleep(seconds):
  510. sleeps.append(seconds)
  511. clock["now"] += seconds
  512. limiter = RateLimiter(
  513. min_interval_seconds=DOUYIN_SEARCH_MIN_INTERVAL_SECONDS,
  514. max_interval_seconds=DOUYIN_SEARCH_MAX_INTERVAL_SECONDS,
  515. now_fn=lambda: clock["now"],
  516. sleep_fn=fake_sleep,
  517. random_fn=lambda lo, hi: lo,
  518. )
  519. limiter.wait("douyin_search")
  520. limiter.wait("douyin_search")
  521. assert sleeps == [10.0]
  522. def test_search_chain_uses_shared_search_bucket():
  523. limiter = FakeRateLimiter()
  524. client = _client(
  525. [
  526. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  527. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  528. ],
  529. rate_limiter=limiter,
  530. )
  531. client.search(_search_query("关键词"))
  532. client.search({**_search_query("关键词"), "page_cursor": "10"})
  533. assert limiter.buckets == ["douyin_search", "douyin_search"]
  534. def test_blogger_uses_separate_bucket_from_search_chain():
  535. limiter = FakeRateLimiter()
  536. client = _client(
  537. [
  538. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  539. _blogger_response(),
  540. ],
  541. rate_limiter=limiter,
  542. )
  543. client.search(_search_query("关键词"))
  544. client.fetch_author_works(_author_query())
  545. assert limiter.buckets == ["douyin_search", "douyin_blogger"]
  546. def test_http_429_maps_to_platform_rate_limited():
  547. client = _client([_response(429, {"error": "too many"})])
  548. with pytest.raises(ContentAgentError) as exc_info:
  549. client.search(_search_query("被限流"))
  550. assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  551. assert exc_info.value.detail["status_code"] == 429
  552. def test_http_429_does_not_fallback_to_candidate():
  553. client = _client(
  554. [
  555. _response(429, {"error": "too many"}),
  556. _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
  557. ],
  558. fallback_base_url="http://candidate.test",
  559. )
  560. with pytest.raises(ContentAgentError) as exc_info:
  561. client.search(_search_query("候选也不能绕限流"))
  562. assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  563. assert len(client.http_client.requests) == 1
  564. assert exc_info.value.detail["crawapi_host_attempts"][0]["role"] == "primary"
  565. def test_business_rate_limit_code_maps_to_platform_rate_limited(monkeypatch):
  566. from content_agent.integrations import douyin
  567. monkeypatch.setattr(douyin, "RATE_LIMIT_BUSINESS_CODES", {"30005"})
  568. client = _client([_response(200, {"code": 30005, "msg": "ok", "data": None})])
  569. with pytest.raises(ContentAgentError) as exc_info:
  570. client.search(_search_query("业务限流"))
  571. assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  572. assert exc_info.value.detail["business_code"] == "30005"
  573. def test_rate_limit_message_token_maps_to_platform_rate_limited():
  574. client = _client([_response(200, {"code": 1, "msg": "请求频繁,请稍后再试", "data": None})])
  575. with pytest.raises(ContentAgentError) as exc_info:
  576. client.search(_search_query("消息限流"))
  577. assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  578. def test_force_login_without_rate_limit_code_is_not_rate_limited():
  579. client = _client([_response(200, {"code": 22001, "msg": "强制登录", "data": None})])
  580. with pytest.raises(RuntimeError, match="business_error"):
  581. client.search(_search_query("强制登录"))
  582. def test_bad_json_is_not_rate_limited():
  583. client = _client(
  584. [
  585. httpx.Response(
  586. 200, content=b"not json",
  587. request=httpx.Request("POST", "http://crawapi.test/endpoint"),
  588. )
  589. ]
  590. )
  591. with pytest.raises(RuntimeError, match="bad_json"):
  592. client.search(_search_query("坏响应"))
  593. def test_plain_500_is_not_rate_limited():
  594. client = _client([_response(500, {"error": "server failed"})])
  595. with pytest.raises(RuntimeError, match="HTTP 500"):
  596. client.search(_search_query("普通失败"))