test_search.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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, parse_search_response, parse_weixin, parse_xiaohongshu, search_keyword,
  10. )
  11. FIX = Path(__file__).parent / "fixtures"
  12. def _settings() -> Settings:
  13. return Settings(
  14. pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
  15. aiddit_crawler_base_url="http://crawler.test", crawler_timeout=30,
  16. openrouter_timeout_seconds=90,
  17. openrouter_model="m",
  18. openrouter_base_url="b", openrouter_api_key="k",
  19. llm_model="m", max_cards=12, frames_dir="f", douyin_ratio="540p", data_dir="")
  20. def _no_wait() -> RateLimiter:
  21. return RateLimiter(min_interval_seconds=0.0)
  22. class _Resp:
  23. def __init__(self, payload): self._p = payload
  24. def raise_for_status(self): return None
  25. def json(self): return self._p
  26. class _FakeClient:
  27. """按调用次序返回预置 payload;记录每次 post 的 body。"""
  28. def __init__(self, pages): self.pages = list(pages); self.calls = []; self.closed = False
  29. def post(self, url, json=None, headers=None, timeout=None):
  30. self.calls.append(json)
  31. return _Resp(self.pages.pop(0))
  32. def close(self): self.closed = True
  33. def test_parse_from_fixture():
  34. resp = json.loads((FIX / "xhs_search_分镜脚本.json").read_text("utf-8"))
  35. ids, has_more, cursor = parse_search_response(resp)
  36. assert ids == ["6a2bce890000000006034be4", "68282529000000002100e28c"]
  37. assert has_more is True and cursor == "2"
  38. def test_parse_business_error_raises():
  39. with pytest.raises(SearchError):
  40. parse_search_response({"code": 10000, "msg": "未知错误", "data": None})
  41. def test_search_dedup_and_limit():
  42. # page1 含重复 id,page2 提供更多;limit=3 → 截断且去重
  43. page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "2",
  44. "data": [{"id": "a"}, {"id": "a"}, {"id": "b"}]}}
  45. page2 = {"code": 0, "data": {"has_more": True, "next_cursor": "3",
  46. "data": [{"id": "b"}, {"id": "c"}, {"id": "d"}]}}
  47. client = _FakeClient([page1, page2])
  48. out = search_keyword("分镜", settings=_settings(), http_client=client,
  49. rate_limiter=_no_wait(), limit=3)
  50. assert out == ["a", "b", "c"]
  51. def test_search_stops_on_no_more():
  52. page1 = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  53. "data": [{"id": "a"}, {"id": "b"}]}}
  54. client = _FakeClient([page1])
  55. out = search_keyword("分镜", settings=_settings(), http_client=client,
  56. rate_limiter=_no_wait(), limit=10)
  57. assert out == ["a", "b"] # has_more=False → 不再翻页,不报错
  58. assert client.calls[0]["keyword"] == "分镜"
  59. def test_search_business_error_raises():
  60. client = _FakeClient([{"code": 10000, "msg": "未知错误", "data": None}])
  61. with pytest.raises(SearchError):
  62. search_keyword("x", settings=_settings(), http_client=client,
  63. rate_limiter=_no_wait())
  64. def test_search_unsupported_platform():
  65. with pytest.raises(SearchError):
  66. search_keyword("x", platform="bilibili", settings=_settings(),
  67. rate_limiter=_no_wait())
  68. def test_parse_douyin_uses_aweme_id():
  69. resp = {"code": 0, "data": {"has_more": True, "next_cursor": "10",
  70. "data": [{"aweme_id": "7618138722512424246"}, {"aweme_id": "7600000000000000000"}]}}
  71. ids, has_more, cursor = parse_search_response(resp, platform="douyin")
  72. assert ids == ["7618138722512424246", "7600000000000000000"]
  73. assert has_more is True and cursor == "10"
  74. def test_douyin_body_has_account_id_and_params():
  75. page = {"code": 0, "data": {"has_more": False, "next_cursor": "",
  76. "data": [{"aweme_id": "a1"}, {"aweme_id": "a2"}]}}
  77. client = _FakeClient([page])
  78. out = search_keyword("科普", platform="douyin", content_type="视频",
  79. settings=_settings(), http_client=client, rate_limiter=_no_wait(), limit=10)
  80. assert out == ["a1", "a2"] # 用 aweme_id 解析
  81. body = client.calls[0]
  82. assert body["account_id"] == "7450041106378522636" # piaoquantv 抖音账号
  83. assert body["cookie_batch"] == "default" # piaoquantv 抖音必带
  84. assert body["sort_type"] == "综合排序" # 平台默认(非小红书的"综合")
  85. assert body["publish_time"] == "不限"
  86. assert body["cursor"] == "0" # 抖音起始 cursor
  87. def test_parse_weixin_cleans_title_and_keeps_url_cover():
  88. resp = {"code": 0, "data": {"data": [
  89. {"title": "复旦教授<em class='highlight'>历史科普</em>", "url": "http://mp.weixin.qq.com/s?x=1",
  90. "cover_url": "https://mmbiz.qpic.cn/a.jpg", "nick_name": "某号", "time": "1天前"},
  91. {"title": "无链接的应被跳过"}, # 无 url → 跳过
  92. ]}}
  93. out = parse_weixin(resp, limit=5)
  94. assert len(out) == 1
  95. assert out[0]["title"] == "复旦教授历史科普" # <em> 标记被清掉
  96. assert out[0]["url"].startswith("http://mp.weixin.qq.com")
  97. assert out[0]["cover_url"].endswith("a.jpg") and out[0]["nick_name"] == "某号"
  98. def test_parse_weixin_business_error():
  99. with pytest.raises(SearchError):
  100. parse_weixin({"code": 10000, "msg": "x"})
  101. def test_parse_xiaohongshu_takes_cover_title_from_note_card():
  102. resp = {"code": 0, "data": {"data": [
  103. {"id": "abc123", "note_card": {
  104. "type": "video", "display_title": "社会运行规则",
  105. "image_list": [{"image_url": "https://ci.xiaohongshu.com/cover.jpg"}],
  106. "user": {"nickname": "某号"}}},
  107. {"id": "noimg", "note_card": {"display_title": "无封面应跳过", "image_list": []}},
  108. ]}}
  109. out = parse_xiaohongshu(resp, limit=5)
  110. assert len(out) == 1 # 无封面的被跳过
  111. assert out[0]["id"] == "abc123" and out[0]["title"] == "社会运行规则"
  112. assert out[0]["cover_url"].endswith("cover.jpg") and out[0]["nick_name"] == "某号"
  113. assert out[0]["url"] == "https://www.xiaohongshu.com/explore/abc123"
  114. def test_parse_xiaohongshu_title_falls_back_to_desc():
  115. resp = {"code": 0, "data": {"data": [
  116. {"id": "x1", "note_card": {"display_title": "", "desc": "那些赤裸裸的社会真相!\n第二行",
  117. "image_list": [{"image_url": "u.jpg"}]}},
  118. ]}}
  119. out = parse_xiaohongshu(resp, limit=5)
  120. assert out[0]["title"] == "那些赤裸裸的社会真相!" # display_title 空 → 取 desc 首行
  121. def test_parse_xiaohongshu_business_error():
  122. with pytest.raises(SearchError):
  123. parse_xiaohongshu({"code": 10000, "msg": "x"})
  124. def test_xiaohongshu_body_no_account_id():
  125. page = {"code": 0, "data": {"has_more": False, "next_cursor": "", "data": [{"id": "x1"}]}}
  126. client = _FakeClient([page])
  127. search_keyword("科普", platform="xiaohongshu", settings=_settings(),
  128. http_client=client, rate_limiter=_no_wait())
  129. body = client.calls[0]
  130. assert "account_id" not in body # 小红书不带
  131. assert body["sort_type"] == "综合" and body["cursor"] == ""