run_search.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """用生成的 query 真实搜帖子/视频(抖音 + 微信公众号 + 小红书),媒体存本地 data/。
  2. 每法取前 N 条 query,每条 query 每个渠道取 top-K:
  3. 抖音:search(top-1) → detail → 下载视频+封面(搜索/详情各自限速)
  4. 微信公众号:search(top-K) → 下载每个封面(封面在搜索回包里,无需 detail)
  5. 小红书:search(top-K) → 下载每个封面(封面在 note_card 里,无需 detail;视频帖无直链)
  6. 限流:三个平台的「搜索」各自独立闸,两次搜索间隔随机 10~12s;抖音「详情」走更轻的 3~5s 闸。
  7. 产出每条记录:{method, query, douyin/weixin/xiaohongshu:{ok:[...],error}}。
  8. 用法:PYTHONPATH=. python scripts/run_search.py
  9. """
  10. from __future__ import annotations
  11. import hashlib
  12. import json
  13. from pathlib import Path
  14. from acquisition.crawler import RateLimiter, fetch_post_detail
  15. from acquisition.search import search_keyword, search_weixin, search_xiaohongshu
  16. from core.config import Settings
  17. from creation_knowledge.integrations.video_extract import _default_download
  18. ROOT = Path(__file__).resolve().parent.parent
  19. DATA = ROOT / "data"
  20. DEMO = DATA / "queries" / "demo.json"
  21. OUT = DATA / "queries" / "search_results.json"
  22. DOUYIN_ON = False # 抖音限流暂停:False 时不发抖音请求,沿用上次结果里的抖音
  23. N = 6 # 每法取前 N 条 query
  24. K_DOUYIN = 1 # 抖音每条 query 只取第 1 个视频(躲限流)
  25. K_WEIXIN = 5 # 微信公众号每条 query 取前 K 个封面
  26. K_XHS = 5 # 小红书每条 query 取前 K 个封面
  27. SEARCH_MIN, SEARCH_MAX = 10.0, 12.0 # 每平台两次搜索之间随机间隔(秒)
  28. def _dl(url: str, platform: str, dst: Path, public: str):
  29. """下载到 dst,返回公开路径;失败返回 None。"""
  30. try:
  31. dst.parent.mkdir(parents=True, exist_ok=True)
  32. dst.write_bytes(_default_download(url, platform))
  33. return public
  34. except Exception:
  35. return None
  36. def douyin_topk(query: str, settings: Settings, search_rl: RateLimiter, detail_rl: RateLimiter) -> dict:
  37. """抖音搜 top-K 视频:返回 {ok:[{title,url,cover,video}], error}。搜索/详情各走各的闸。"""
  38. try:
  39. ids = search_keyword(query, platform="douyin", content_type="视频", limit=K_DOUYIN,
  40. settings=settings, rate_limiter=search_rl)
  41. except Exception as exc:
  42. return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"}
  43. if not ids:
  44. return {"ok": [], "error": "未搜到"}
  45. recs = []
  46. for vid in ids[:K_DOUYIN]:
  47. try:
  48. post = fetch_post_detail(vid, settings=settings, rate_limiter=detail_rl)
  49. except Exception:
  50. continue
  51. base, pub = DATA / "search" / "douyin" / post.id, f"/data/search/douyin/{post.id}"
  52. rec = {"title": post.title, "url": post.url, "cover": None, "video": None}
  53. if post.image_urls:
  54. rec["cover"] = _dl(post.image_urls[0], "douyin", base / "cover.jpg", pub + "/cover.jpg")
  55. if post.video_urls:
  56. rec["video"] = _dl(post.video_urls[0], "douyin", base / "video.mp4", pub + "/video.mp4")
  57. if rec["video"] or rec["cover"]:
  58. recs.append(rec)
  59. return {"ok": recs, "error": None if recs else "详情/下载均失败"}
  60. def weixin_topk(query: str, settings: Settings, rl: RateLimiter) -> dict:
  61. """微信公众号搜 top-K 文章封面:返回 {ok:[{title,url,nick,cover}], error}。"""
  62. try:
  63. arts = search_weixin(query, limit=K_WEIXIN, settings=settings, rate_limiter=rl)
  64. except Exception as exc:
  65. return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"}
  66. if not arts:
  67. return {"ok": [], "error": "未搜到"}
  68. recs = []
  69. for a in arts[:K_WEIXIN]:
  70. h = hashlib.md5(a["url"].encode()).hexdigest()[:16]
  71. base, pub = DATA / "search" / "weixin" / h, f"/data/search/weixin/{h}"
  72. rec = {"title": a["title"], "url": a["url"], "nick": a["nick_name"], "cover": None}
  73. if a["cover_url"]:
  74. rec["cover"] = _dl(a["cover_url"], "weixin", base / "cover.jpg", pub + "/cover.jpg")
  75. recs.append(rec)
  76. return {"ok": recs, "error": None}
  77. def xiaohongshu_topk(query: str, settings: Settings, rl: RateLimiter) -> dict:
  78. """小红书搜 top-K 帖子封面:返回 {ok:[{title,url,nick,cover}], error}。封面来自搜索回包,无需 detail。"""
  79. try:
  80. posts = search_xiaohongshu(query, content_type="图文", limit=K_XHS,
  81. settings=settings, rate_limiter=rl)
  82. except Exception as exc:
  83. return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"}
  84. if not posts:
  85. return {"ok": [], "error": "未搜到"}
  86. recs = []
  87. for p in posts[:K_XHS]:
  88. base, pub = DATA / "search" / "xiaohongshu" / p["id"], f"/data/search/xiaohongshu/{p['id']}"
  89. rec = {"title": p["title"], "url": p["url"], "nick": p["nick_name"], "cover": None}
  90. if p["cover_url"]:
  91. rec["cover"] = _dl(p["cover_url"], "xiaohongshu", base / "cover.jpg", pub + "/cover.jpg")
  92. recs.append(rec)
  93. return {"ok": recs, "error": None}
  94. def _prev_douyin() -> dict:
  95. """抖音关闭时,从上次的 search_results.json 沿用抖音结果(按 query 文本取),保住已抓到的视频。"""
  96. if not OUT.exists():
  97. return {}
  98. try:
  99. prev = json.loads(OUT.read_text("utf-8"))
  100. except Exception:
  101. return {}
  102. return {r["query"]: r.get("douyin") for r in prev if r.get("query")}
  103. def main() -> None:
  104. settings = Settings.from_env()
  105. demo = json.loads(DEMO.read_text("utf-8"))
  106. # 三平台搜索各自独立闸(随机 10~12s);抖音详情更轻(3~5s)
  107. dy_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  108. dy_detail = RateLimiter(min_interval_seconds=3.0, max_interval_seconds=5.0)
  109. wx_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  110. xhs_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  111. prev_dy = {} if DOUYIN_ON else _prev_douyin()
  112. if not DOUYIN_ON:
  113. print(f"(抖音已关闭:沿用上次 {len(prev_dy)} 条 query 的抖音结果,本次只跑微信+小红书)")
  114. results = []
  115. for tac in ("tactic1", "tactic2", "tactic4"):
  116. t = demo.get(tac) or {}
  117. name = t.get("name", tac)
  118. for it in (t.get("items") or [])[:N]:
  119. q = it.get("query")
  120. if not q:
  121. continue
  122. print(f"[{name}] {q}")
  123. if DOUYIN_ON:
  124. dy = douyin_topk(q, settings, dy_search, dy_detail)
  125. else:
  126. dy = prev_dy.get(q) or {"ok": [], "error": "抖音限流暂停,未跑"}
  127. wx = weixin_topk(q, settings, wx_search)
  128. xhs = xiaohongshu_topk(q, settings, xhs_search)
  129. print(f" 抖音 {len(dy['ok'])} 视频{_err(dy)}"
  130. f" 微信 {len(wx['ok'])} 封面{_err(wx)}"
  131. f" 小红书 {len(xhs['ok'])} 封面{_err(xhs)}")
  132. results.append({"method": name, "query": q, "douyin": dy, "weixin": wx, "xiaohongshu": xhs})
  133. OUT.parent.mkdir(parents=True, exist_ok=True)
  134. OUT.write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
  135. tot = lambda k: sum(len(r[k]["ok"]) for r in results)
  136. print(f"\nwrote {len(results)} 条 query(抖音 {tot('douyin')} / 微信 {tot('weixin')} / "
  137. f"小红书 {tot('xiaohongshu')})→ {OUT}")
  138. def _err(d: dict) -> str:
  139. return " / " + d["error"] if d.get("error") else ""
  140. if __name__ == "__main__":
  141. main()