test_search.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """关键词搜索离线测试:纯 parser + search_keyword(去重/翻页/截断/SearchError)。"""
  2. from __future__ import annotations
  3. import json
  4. from pathlib import Path
  5. import pytest
  6. from core.config import PgConfig, Settings
  7. from acquisition.crawler import RateLimiter
  8. from acquisition.search import (
  9. SearchError,
  10. parse_search_response,
  11. parse_weixin,
  12. parse_xiaohongshu,
  13. search_douyin_hits,
  14. search_keyword,
  15. search_xiaohongshu,
  16. )
  17. FIX = Path(__file__).parent / "fixtures"
  18. def _settings() -> Settings:
  19. return Settings(
  20. pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
  21. aiddit_crawler_base_url="http://crawler.test", crawler_timeout=30,
  22. openrouter_timeout_seconds=90,
  23. openrouter_model="m",
  24. openrouter_base_url="b", openrouter_api_key="k",
  25. llm_model="m", max_cards=12, frames_dir="f", douyin_ratio="540p", data_dir="")
  26. def _no_wait() -> RateLimiter:
  27. return RateLimiter(min_interval_seconds=0.0)
  28. class _Resp:
  29. def __init__(self, payload): self._p = payload
  30. def raise_for_status(self): return None
  31. def json(self): return self._p
  32. class _FakeClient:
  33. """按调用次序返回预置 payload;记录每次 post 的 body。"""
  34. def __init__(self, pages): self.pages = list(pages); self.calls = []; self.closed = False
  35. def post(self, url, json=None, headers=None, timeout=None):
  36. self.calls.append(json)
  37. return _Resp(self.pages.pop(0))
  38. def close(self): self.closed = True
  39. class _FakeClientWithUrls:
  40. """按调用次序返回预置 payload;记录每次 post 的 url/body。"""
  41. def __init__(self, pages): self.pages = list(pages); self.calls = []; self.closed = False
  42. def post(self, url, json=None, headers=None, timeout=None):
  43. self.calls.append({"url": url, "body": json})
  44. return _Resp(self.pages.pop(0))
  45. def close(self): self.closed = True
  46. class _RecordingLimiter:
  47. def __init__(self):
  48. self.buckets = []
  49. def wait(self, bucket: str):
  50. self.buckets.append(bucket)
  51. def test_parse_from_fixture():
  52. resp = json.loads((FIX / "xhs_search_分镜脚本.json").read_text("utf-8"))
  53. ids, has_more, cursor = parse_search_response(resp)
  54. assert ids == ["6a2bce890000000006034be4", "68282529000000002100e28c"]
  55. assert has_more is True and cursor == "2"
  56. def test_parse_business_error_raises():
  57. with pytest.raises(SearchError):
  58. parse_search_response({"code": 10000, "msg": "未知错误", "data": None})
  59. def test_search_dedup_and_limit():
  60. # page1 含重复 id,page2 提供更多;limit=3 → 截断且去重
  61. page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "2",
  62. "data": [{"id": "a"}, {"id": "a"}, {"id": "b"}]}}
  63. page2 = {"code": 0, "data": {"has_more": True, "next_cursor": "3",
  64. "data": [{"id": "b"}, {"id": "c"}, {"id": "d"}]}}
  65. client = _FakeClient([page1, page2])
  66. out = search_keyword("分镜", settings=_settings(), http_client=client,
  67. rate_limiter=_no_wait(), limit=3)
  68. assert out == ["a", "b", "c"]
  69. def test_search_stops_on_no_more():
  70. page1 = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  71. "data": [{"id": "a"}, {"id": "b"}]}}
  72. client = _FakeClient([page1])
  73. out = search_keyword("分镜", settings=_settings(), http_client=client,
  74. rate_limiter=_no_wait(), limit=10)
  75. assert out == ["a", "b"] # has_more=False → 不再翻页,不报错
  76. assert client.calls[0]["keyword"] == "分镜"
  77. def test_search_business_error_raises():
  78. client = _FakeClient([{"code": 10000, "msg": "未知错误", "data": None}])
  79. with pytest.raises(SearchError):
  80. search_keyword("x", settings=_settings(), http_client=client,
  81. rate_limiter=_no_wait())
  82. def test_search_unsupported_platform():
  83. with pytest.raises(SearchError):
  84. search_keyword("x", platform="bilibili", settings=_settings(),
  85. rate_limiter=_no_wait())
  86. def test_parse_douyin_uses_aweme_id():
  87. resp = {"code": 0, "data": {"has_more": True, "next_cursor": "10",
  88. "data": [{"aweme_id": "7618138722512424246"}, {"aweme_id": "7600000000000000000"}]}}
  89. ids, has_more, cursor = parse_search_response(resp, platform="douyin")
  90. assert ids == ["7618138722512424246", "7600000000000000000"]
  91. assert has_more is True and cursor == "10"
  92. def test_douyin_body_has_account_id_and_params():
  93. page = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  94. "data": [{"aweme_id": "a1"}, {"aweme_id": "a2"}]}}
  95. client = _FakeClient([page])
  96. out = search_keyword("科普", platform="douyin", content_type="视频",
  97. settings=_settings(), http_client=client, rate_limiter=_no_wait(), limit=10)
  98. assert out == ["a1", "a2"] # 用 aweme_id 解析
  99. body = client.calls[0]
  100. assert body["account_id"] == "7450041106378522636" # piaoquantv 抖音账号
  101. assert body["cookie_batch"] == "default" # piaoquantv 抖音必带
  102. assert body["sort_type"] == "综合排序" # 平台默认(非小红书的"综合")
  103. assert body["publish_time"] == "不限"
  104. assert body["cursor"] == "0" # 抖音起始 cursor
  105. def test_douyin_search_hits_success_does_not_request_aiddit():
  106. page = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  107. "data": [{"aweme_id": "a1", "title": "ok"}]}}
  108. client = _FakeClientWithUrls([page])
  109. limiter = _RecordingLimiter()
  110. out = search_douyin_hits(
  111. "科普",
  112. settings=_settings(),
  113. http_client=client,
  114. rate_limiter=limiter,
  115. limit=1,
  116. )
  117. assert [hit.content_id for hit in out] == ["a1"]
  118. assert out[0].provider == "piaoquantv"
  119. assert out[0].raw["title"] == "ok"
  120. assert len(client.calls) == 1
  121. assert client.calls[0]["url"].startswith("http://crawapi.piaoquantv.com/")
  122. assert limiter.buckets == ["douyin"]
  123. def test_douyin_search_hits_falls_back_to_aiddit_with_separate_body():
  124. piao_error = {"code": 10000, "msg": "captcha", "data": None}
  125. aiddit_page = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  126. "data": [{"aweme_id": "aid-1", "desc": "aiddit"}]}}
  127. client = _FakeClientWithUrls([piao_error, aiddit_page])
  128. limiter = _RecordingLimiter()
  129. out = search_douyin_hits(
  130. "厦门",
  131. settings=_settings(),
  132. http_client=client,
  133. rate_limiter=limiter,
  134. limit=1,
  135. )
  136. assert out[0].content_id == "aid-1"
  137. assert out[0].provider == "aiddit"
  138. assert len(client.calls) == 2
  139. piao_body = client.calls[0]["body"]
  140. aiddit_body = client.calls[1]["body"]
  141. assert piao_body["account_id"] == "7450041106378522636"
  142. assert piao_body["cookie_batch"] == "default"
  143. assert piao_body["publish_time"] == "不限"
  144. assert piao_body["cursor"] == "0"
  145. assert aiddit_body["cursor"] == ""
  146. assert aiddit_body["sort_type"] == "最多点赞"
  147. assert "account_id" not in aiddit_body
  148. assert "cookie_batch" not in aiddit_body
  149. assert "publish_time" not in aiddit_body
  150. assert client.calls[1]["url"].startswith("http://crawler.test/")
  151. assert limiter.buckets == ["douyin", "douyin"]
  152. def test_douyin_search_hits_falls_back_when_no_usable_aweme_id():
  153. piao_empty = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  154. "data": [{"id": "not-aweme"}]}}
  155. aiddit_page = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  156. "data": [{"aweme_id": "aid-2"}]}}
  157. client = _FakeClientWithUrls([piao_empty, aiddit_page])
  158. out = search_douyin_hits(
  159. "厦门",
  160. settings=_settings(),
  161. http_client=client,
  162. rate_limiter=_RecordingLimiter(),
  163. limit=1,
  164. )
  165. assert out[0].provider == "aiddit"
  166. assert out[0].content_id == "aid-2"
  167. def test_douyin_search_hits_paginates_with_successful_provider_cursor():
  168. page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "10",
  169. "data": [{"aweme_id": "a1"}]}}
  170. page2 = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  171. "data": [{"aweme_id": "a2"}]}}
  172. client = _FakeClientWithUrls([page1, page2])
  173. out = search_douyin_hits(
  174. "厦门",
  175. settings=_settings(),
  176. http_client=client,
  177. rate_limiter=_RecordingLimiter(),
  178. limit=2,
  179. )
  180. assert [hit.content_id for hit in out] == ["a1", "a2"]
  181. assert client.calls[0]["body"]["cursor"] == "0"
  182. assert client.calls[1]["body"]["cursor"] == "10"
  183. def test_douyin_search_hits_switches_provider_with_provider_own_cursor():
  184. piao_page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "10",
  185. "data": [{"aweme_id": "p1"}]}}
  186. piao_page2_error = {"code": 10000, "msg": "captcha", "data": None}
  187. aiddit_page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "12",
  188. "data": [{"aweme_id": "a1"}]}}
  189. client = _FakeClientWithUrls([piao_page1, piao_page2_error, aiddit_page1])
  190. out = search_douyin_hits(
  191. "厦门",
  192. settings=_settings(),
  193. http_client=client,
  194. rate_limiter=_RecordingLimiter(),
  195. limit=2,
  196. )
  197. assert [(hit.content_id, hit.provider) for hit in out] == [
  198. ("p1", "piaoquantv"),
  199. ("a1", "aiddit"),
  200. ]
  201. assert client.calls[1]["body"]["cursor"] == "10"
  202. assert client.calls[2]["body"]["cursor"] == ""
  203. def test_parse_weixin_cleans_title_and_keeps_url_cover():
  204. resp = {"code": 0, "data": {"data": [
  205. {"title": "复旦教授<em class='highlight'>历史科普</em>", "url": "http://mp.weixin.qq.com/s?x=1",
  206. "cover_url": "https://mmbiz.qpic.cn/a.jpg", "nick_name": "某号", "time": "1天前"},
  207. {"title": "无链接的应被跳过"}, # 无 url → 跳过
  208. ]}}
  209. out = parse_weixin(resp, limit=5)
  210. assert len(out) == 1
  211. assert out[0]["title"] == "复旦教授历史科普" # <em> 标记被清掉
  212. assert out[0]["url"].startswith("http://mp.weixin.qq.com")
  213. assert out[0]["cover_url"].endswith("a.jpg") and out[0]["nick_name"] == "某号"
  214. def test_parse_weixin_business_error():
  215. with pytest.raises(SearchError):
  216. parse_weixin({"code": 10000, "msg": "x"})
  217. def test_parse_xiaohongshu_takes_cover_title_from_note_card():
  218. resp = {"code": 0, "data": {"data": [
  219. {"id": "abc123", "note_card": {
  220. "type": "video", "display_title": "社会运行规则",
  221. "image_list": [{"image_url": "https://ci.xiaohongshu.com/cover.jpg"}],
  222. "user": {"nickname": "某号"}}},
  223. {"id": "noimg", "note_card": {"display_title": "无封面应跳过", "image_list": []}},
  224. ]}}
  225. out = parse_xiaohongshu(resp, limit=5)
  226. assert len(out) == 1 # 无封面的被跳过
  227. assert out[0]["id"] == "abc123" and out[0]["title"] == "社会运行规则"
  228. assert out[0]["cover_url"].endswith("cover.jpg") and out[0]["nick_name"] == "某号"
  229. assert out[0]["url"] == "https://www.xiaohongshu.com/explore/abc123"
  230. def test_xiaohongshu_search_omits_content_type_by_default():
  231. page = {"code": 0, "data": {"data": [
  232. {"id": "abc", "note_card": {
  233. "type": "video",
  234. "display_title": "混合结果",
  235. "image_list": [{"image_url": "https://ci.test/c.jpg"}],
  236. }}
  237. ]}}
  238. client = _FakeClient([page])
  239. out = search_xiaohongshu(
  240. "符号 视频 灵感 怎么做",
  241. settings=_settings(),
  242. http_client=client,
  243. rate_limiter=_no_wait(),
  244. limit=1,
  245. )
  246. assert out[0]["id"] == "abc"
  247. assert "content_type" not in client.calls[0]
  248. def test_parse_xiaohongshu_title_falls_back_to_desc():
  249. resp = {"code": 0, "data": {"data": [
  250. {"id": "x1", "note_card": {"display_title": "", "desc": "那些赤裸裸的社会真相!\n第二行",
  251. "image_list": [{"image_url": "u.jpg"}]}},
  252. ]}}
  253. out = parse_xiaohongshu(resp, limit=5)
  254. assert out[0]["title"] == "那些赤裸裸的社会真相!" # display_title 空 → 取 desc 首行
  255. def test_parse_xiaohongshu_business_error():
  256. with pytest.raises(SearchError):
  257. parse_xiaohongshu({"code": 10000, "msg": "x"})
  258. def test_xiaohongshu_body_no_account_id():
  259. page = {"code": 0, "data": {"has_more": False, "next_cursor": "", "data": [{"id": "x1"}]}}
  260. client = _FakeClient([page])
  261. search_keyword("科普", platform="xiaohongshu", settings=_settings(),
  262. http_client=client, rate_limiter=_no_wait())
  263. body = client.calls[0]
  264. assert "account_id" not in body # 小红书不带
  265. assert body["sort_type"] == "综合" and body["cursor"] == ""