test_creation_search.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. from __future__ import annotations
  2. import httpx
  3. import acquisition.classify as classify_module
  4. import acquisition.creation_search as creation_search_module
  5. from acquisition import store
  6. from acquisition.classify import _providers, classify_imgtext, classify_video
  7. from acquisition.creation_search import Candidate, PlatformRateLimiter, run_platform_query
  8. from acquisition.crawler import RateLimiter
  9. from core.models import Post
  10. from core.config import PgConfig, Settings
  11. from scripts import run_creation_search as run_cli
  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",
  16. crawler_timeout=30,
  17. openrouter_timeout_seconds=90,
  18. openrouter_model="m",
  19. openrouter_base_url="http://openrouter.test",
  20. openrouter_api_key="k",
  21. llm_model="m",
  22. max_cards=12,
  23. frames_dir="f",
  24. douyin_ratio="540p",
  25. data_dir="data",
  26. )
  27. def test_creation_store_summary_and_detail(tmp_path):
  28. c = store.connect(tmp_path / "app.db")
  29. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  30. store.ensure_creation_job(c, "r1", "q", "douyin", ts=1)
  31. item_id = store.upsert_creation_item(
  32. c,
  33. run_id="r1",
  34. query="q",
  35. platform="douyin",
  36. rank=1,
  37. source_id="dy1",
  38. url="https://www.douyin.com/video/1",
  39. title="标题",
  40. cover_url="https://cover.test/a.jpg",
  41. video_url="https://cdn.test/v.mp4",
  42. is_displayable=True,
  43. ts=2,
  44. )
  45. store.upsert_creation_classification(
  46. c, item_id, 1, reason="教你做视频", knowledge="先选题再写脚本", prompt_version="abc", ts=3
  47. )
  48. xhs_creation = store.upsert_creation_item(
  49. c, run_id="r1", query="q", platform="xiaohongshu", rank=1,
  50. title="小红书创作帖", image_urls=["/data/x1.jpg"], is_displayable=True, ts=3,
  51. )
  52. xhs_other = store.upsert_creation_item(
  53. c, run_id="r1", query="q", platform="xiaohongshu", rank=2,
  54. title="小红书非创作帖", image_urls=["/data/x2.jpg"], is_displayable=True, ts=3,
  55. )
  56. xhs_hidden = store.upsert_creation_item(
  57. c, run_id="r1", query="q", platform="xiaohongshu", rank=3,
  58. title="非展示候选", image_urls=["/data/x3.jpg"], is_displayable=False, ts=3,
  59. )
  60. wx_creation = store.upsert_creation_item(
  61. c, run_id="r1", query="q", platform="weixin", rank=1,
  62. title="公众号创作帖", image_urls=["/data/w1.jpg"], is_displayable=True, ts=3,
  63. )
  64. store.upsert_creation_classification(c, xhs_creation, 1, reason="ok", knowledge="k", ts=3)
  65. store.upsert_creation_classification(c, xhs_other, 0, reason="no", knowledge="", ts=3)
  66. store.upsert_creation_classification(c, xhs_hidden, 1, reason="hidden", knowledge="hidden", ts=3)
  67. store.upsert_creation_classification(c, wx_creation, 1, reason="ok", knowledge="k", ts=3)
  68. store.update_creation_job(
  69. c, "r1", "q", "douyin", status="done", attempts=1,
  70. searched_count=10, display_count=1, ts=4
  71. )
  72. summary = store.creation_search_summary(c)
  73. assert summary["run_id"] == "r1"
  74. q_summary = summary["queries"]["q"]
  75. assert q_summary["platforms"]["douyin"]["display_count"] == 1
  76. assert q_summary["imgtext_creation_count"] == 2
  77. assert q_summary["imgtext_classified_count"] == 3
  78. assert q_summary["imgtext_total_count"] == 3
  79. assert q_summary["imgtext_target_count"] == 10
  80. detail = store.get_creation_query_detail(c, "q")
  81. item = detail["platforms"]["douyin"]["items"][0]
  82. assert item["video_url"] == "https://cdn.test/v.mp4"
  83. assert item["classification"]["is_creation"] == 1
  84. assert item["classification"]["knowledge"] == "先选题再写脚本"
  85. def test_platform_rate_limiter_shares_keyword_and_detail_bucket():
  86. now = {"t": 0.0}
  87. sleeps = []
  88. def now_fn():
  89. return now["t"]
  90. def sleep_fn(seconds):
  91. sleeps.append(seconds)
  92. now["t"] += seconds
  93. base = RateLimiter(
  94. min_interval_seconds=10.0,
  95. max_interval_seconds=10.0,
  96. now_fn=now_fn,
  97. sleep_fn=sleep_fn,
  98. )
  99. gate = PlatformRateLimiter("douyin", delegate=base)
  100. gate.wait("douyin_keyword")
  101. now["t"] += 1.0
  102. gate.wait("douyin_detail")
  103. assert sleeps == [9.0]
  104. def test_classify_video_accepts_cdn_url(monkeypatch):
  105. seen = {}
  106. def fake_judge(messages, settings, timeout):
  107. seen["url"] = messages[1]["content"][1]["video_url"]["url"]
  108. return 1, "ok", "knowledge", ""
  109. monkeypatch.setattr("acquisition.classify._judge", fake_judge)
  110. res = classify_video({"video": "https://cdn.test/video.mp4"}, _settings())
  111. assert res == (1, "ok", "knowledge", "")
  112. assert seen["url"] == "https://cdn.test/video.mp4"
  113. def test_classify_imgtext_maps_public_data_to_settings_data_dir(tmp_path, monkeypatch):
  114. legacy = tmp_path / "legacy_data"
  115. img = legacy / "media" / "x.jpg"
  116. img.parent.mkdir(parents=True)
  117. img.write_bytes(b"\xff\xd8fake-jpeg")
  118. settings = _settings()
  119. settings.data_dir = str(legacy)
  120. seen = {}
  121. def fake_judge(messages, settings, timeout):
  122. seen["parts"] = messages[1]["content"]
  123. return 1, "ok", "knowledge", ""
  124. monkeypatch.setattr("acquisition.classify._judge", fake_judge)
  125. res = classify_imgtext({"images": ["/data/media/x.jpg"]}, settings)
  126. assert res == (1, "ok", "knowledge", "")
  127. image_parts = [p for p in seen["parts"] if p["type"] == "image_url"]
  128. assert image_parts and image_parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
  129. def test_qwen_provider_uses_dashscope_credentials(monkeypatch):
  130. monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
  131. monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
  132. monkeypatch.setenv("ALIYUN_BAILIAN_API_KEY", "sk-test")
  133. monkeypatch.setenv("ALIYUN_BAILIAN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
  134. monkeypatch.setenv("OPENROUTER_MODEL", "google/gemini-3-flash-preview")
  135. providers = _providers(_settings(), [{"role": "user", "content": "ping"}])
  136. assert [p[0] for p in providers] == ["qwen:qwen3.7-plus", "qwen:qwen-vl-plus"]
  137. assert providers[0][1] == "https://dashscope.test/compatible-mode/v1/chat/completions"
  138. assert providers[0][3]["model"] == "qwen3.7-plus"
  139. def test_provider_limiter_and_429_backoff_are_provider_scoped(monkeypatch):
  140. assert classify_module._provider_min_interval("qwen", {"CLASSIFY_QWEN_MIN_INTERVAL_SECONDS": "0.25"}) == 0.25
  141. assert classify_module._provider_min_interval("ark", {"CLASSIFY_ARK_MIN_INTERVAL_SECONDS": "0.75"}) == 0.75
  142. now = {"t": 0.0}
  143. sleeps = []
  144. calls = {"n": 0}
  145. classify_module._PROVIDER_THROTTLES.clear()
  146. monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
  147. monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
  148. monkeypatch.setenv("ALIYUN_BAILIAN_API_KEY", "sk-test")
  149. monkeypatch.setenv("ALIYUN_BAILIAN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
  150. monkeypatch.setenv("CLASSIFY_QWEN_MIN_INTERVAL_SECONDS", "0")
  151. monkeypatch.setenv("CLASSIFY_QWEN_429_BACKOFF_SECONDS", "9,30,90")
  152. monkeypatch.setattr(classify_module.time, "monotonic", lambda: now["t"])
  153. def fake_sleep(seconds):
  154. sleeps.append(seconds)
  155. now["t"] += seconds
  156. def fake_post(*args, **kwargs):
  157. calls["n"] += 1
  158. if calls["n"] == 1:
  159. return httpx.Response(429, request=httpx.Request("POST", "https://dashscope.test"))
  160. return httpx.Response(
  161. 200,
  162. json={"choices": [{"message": {"content": '{"is_empty": false, "reason": "ok", "knowledge": "k"}'}}]},
  163. request=httpx.Request("POST", "https://dashscope.test"),
  164. )
  165. monkeypatch.setattr(classify_module.time, "sleep", fake_sleep)
  166. monkeypatch.setattr(classify_module.httpx, "post", fake_post)
  167. result = classify_module._judge([{"role": "user", "content": "ping"}], _settings(), timeout=1)
  168. assert result == (1, "ok", "k", "")
  169. assert calls["n"] == 2
  170. assert sleeps == [2, 7.0]
  171. def test_run_platform_query_skips_bad_items_and_fills_display_limit(tmp_path, monkeypatch):
  172. c = store.connect(tmp_path / "app.db")
  173. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  174. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  175. def fake_search(platform, query, *, settings, limit, rate_limiter):
  176. assert platform == "weixin"
  177. return [Candidate(rank=i, url=f"https://mp.test/{i}", title=f"t{i}") for i in range(1, 8)]
  178. def fake_process(platform, candidate, *, query_hash, settings, rate_limiter, downloader):
  179. if candidate.rank == 1:
  180. raise RuntimeError("detail failed")
  181. return {
  182. "source_id": str(candidate.rank),
  183. "url": candidate.url,
  184. "title": candidate.title,
  185. "body_text": "正文",
  186. "cover_url": "/data/a.jpg",
  187. "image_urls": ["/data/a.jpg"],
  188. "video_url": "",
  189. "raw": {},
  190. }
  191. monkeypatch.setattr("acquisition.creation_search.search_candidates", fake_search)
  192. monkeypatch.setattr("acquisition.creation_search.process_candidate", fake_process)
  193. monkeypatch.setattr("acquisition.creation_search._classify_and_store", lambda *a, **k: None)
  194. res = run_platform_query(
  195. c,
  196. run_id="r1",
  197. query="q",
  198. platform="weixin",
  199. settings=_settings(),
  200. search_limit=7,
  201. display_limit=5,
  202. classify=True,
  203. sleep_fn=lambda _: None,
  204. )
  205. assert res["status"] == "done"
  206. assert res["display_count"] == 5
  207. detail = store.get_creation_query_detail(c, "q")
  208. assert len(detail["platforms"]["weixin"]["items"]) == 5
  209. def test_xhs_media_saved_under_settings_data_dir(tmp_path, monkeypatch):
  210. settings = _settings()
  211. settings.data_dir = str(tmp_path / "legacy_data")
  212. post = Post(
  213. id="xhs_1", platform="xiaohongshu", url="https://xhs.test/1",
  214. content_id="cid1", title="t", body_text="b", image_urls=["https://img.test/a.jpg"],
  215. )
  216. monkeypatch.setattr(creation_search_module, "fetch_post_detail", lambda *a, **k: post)
  217. out = creation_search_module._process_xhs(
  218. Candidate(rank=1, source_id="cid1"),
  219. query_hash="qh",
  220. settings=settings,
  221. rate_limiter=PlatformRateLimiter("xiaohongshu", delegate=RateLimiter(
  222. min_interval_seconds=0, max_interval_seconds=0, sleep_fn=lambda _: None,
  223. )),
  224. downloader=lambda url, platform: b"\xff\xd8fake-jpeg",
  225. )
  226. expected = tmp_path / "legacy_data" / "media" / "xiaohongshu" / "qh" / "cid1" / "image_1.jpg"
  227. assert expected.exists()
  228. assert out["image_urls"] == ["/data/media/xiaohongshu/qh/cid1/image_1.jpg"]
  229. def test_creation_job_done_and_classification_queue(tmp_path):
  230. c = store.connect(tmp_path / "app.db")
  231. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  232. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  233. assert not store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
  234. store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
  235. assert store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
  236. missing = store.upsert_creation_item(
  237. c, run_id="r1", query="q", platform="weixin", rank=1,
  238. title="missing", image_urls=["/data/a.jpg"], is_displayable=True, ts=3,
  239. )
  240. failed = store.upsert_creation_item(
  241. c, run_id="r1", query="q", platform="weixin", rank=2,
  242. title="failed", image_urls=["/data/b.jpg"], is_displayable=True, ts=3,
  243. )
  244. done = store.upsert_creation_item(
  245. c, run_id="r1", query="q", platform="weixin", rank=3,
  246. title="done", image_urls=["/data/c.jpg"], is_displayable=True, ts=3,
  247. )
  248. store.upsert_creation_classification(c, failed, None, reason="bad", error="bad", ts=4)
  249. store.upsert_creation_classification(c, done, 1, reason="ok", knowledge="k", ts=4)
  250. retry_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=True)
  251. missing_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=False)
  252. assert {r["id"] for r in retry_rows} == {missing, failed}
  253. assert {r["id"] for r in missing_rows} == {missing}
  254. def test_run_workers_isolates_worker_exception(tmp_path, monkeypatch):
  255. db = tmp_path / "app.db"
  256. c = store.connect(db)
  257. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  258. for p in ["xiaohongshu", "weixin"]:
  259. store.ensure_creation_job(c, "r1", "q", p, ts=1)
  260. c.close()
  261. def fake_run(conn, *, platform, run_id, query, display_limit, **kwargs):
  262. if platform == "xiaohongshu":
  263. raise RuntimeError("boom")
  264. store.update_creation_job(
  265. conn, run_id, query, platform, status="done",
  266. searched_count=10, display_count=display_limit, ts=2,
  267. )
  268. return {"platform": platform, "status": "done", "display_count": display_limit, "error": ""}
  269. monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
  270. failed = run_cli.run_platform_workers(
  271. run_id="r1", queries=["q"], platforms=["xiaohongshu", "weixin"],
  272. settings=_settings(), search_limit=10, display_limit=5,
  273. classify=False, skip_done=False, db_path=db,
  274. )
  275. c = store.connect(db)
  276. summary = store.creation_search_summary(c, run_id="r1")["queries"]["q"]["platforms"]
  277. assert failed == 1
  278. assert summary["xiaohongshu"]["status"] == "failed"
  279. assert "worker异常" in summary["xiaohongshu"]["error"]
  280. assert summary["weixin"]["status"] == "done"
  281. def test_run_workers_skip_done_does_not_rerun(tmp_path, monkeypatch):
  282. db = tmp_path / "app.db"
  283. c = store.connect(db)
  284. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  285. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  286. store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
  287. c.close()
  288. calls = []
  289. def fake_run(*args, **kwargs):
  290. calls.append(kwargs)
  291. return {"platform": "weixin", "status": "done", "display_count": 5, "error": ""}
  292. monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
  293. failed = run_cli.run_platform_workers(
  294. run_id="r1", queries=["q"], platforms=["weixin"],
  295. settings=_settings(), search_limit=10, display_limit=5,
  296. classify=False, skip_done=True, db_path=db,
  297. )
  298. assert failed == 0
  299. assert calls == []
  300. def test_run_workers_resolves_env_db_path(tmp_path, monkeypatch):
  301. db = tmp_path / "legacy_data" / "app.db"
  302. c = store.connect(db)
  303. store.create_creation_run(c, "r1", total_queries=1, ts=1)
  304. store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
  305. c.close()
  306. monkeypatch.setenv("CK_SQLITE_PATH", str(db))
  307. def fake_run(conn, *, platform, run_id, query, display_limit, **kwargs):
  308. store.update_creation_job(
  309. conn, run_id, query, platform, status="done",
  310. searched_count=10, display_count=display_limit, ts=2,
  311. )
  312. return {"platform": platform, "status": "done", "display_count": display_limit, "error": ""}
  313. monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
  314. failed = run_cli.run_platform_workers(
  315. run_id="r1", queries=["q"], platforms=["weixin"],
  316. settings=_settings(), search_limit=10, display_limit=5,
  317. classify=False, skip_done=False,
  318. )
  319. c = store.connect(db)
  320. summary = store.creation_search_summary(c, run_id="r1")["queries"]["q"]["platforms"]
  321. assert failed == 0
  322. assert summary["weixin"]["status"] == "done"