run_search.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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 = True # 抖音走 piaoquantv,真跑
  23. N = 6 # 每法取前 N 条 query
  24. K_DOUYIN = 3 # 抖音每条 query 取前 3 个视频(每个要 1 详情 + 下 mp4,最重,故取少)
  25. K_WEIXIN = 10 # 微信公众号每条 query 取前 10 个封面
  26. K_XHS = 10 # 小红书每条 query 取前 10 帖(逐帖下完整图+正文)
  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 _dedup(urls):
  78. seen, out = set(), []
  79. for u in urls:
  80. if u and u not in seen:
  81. seen.add(u)
  82. out.append(u)
  83. return out
  84. def xiaohongshu_topk(query: str, settings: Settings, search_rl: RateLimiter,
  85. detail_rl: RateLimiter) -> dict:
  86. """小红书搜 top-K → 逐条调详情拉【完整帖子】:下载全部图片 + 存正文到本地。
  87. 小红书链接会被反爬封,必须把整帖存档,不能只留封面+链接。返回 {ok:[{title,url,nick,cover,images,body_text}], error}。"""
  88. try:
  89. posts = search_xiaohongshu(query, content_type="图文", limit=K_XHS,
  90. settings=settings, rate_limiter=search_rl)
  91. except Exception as exc:
  92. return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"}
  93. if not posts:
  94. return {"ok": [], "error": "未搜到"}
  95. recs = []
  96. for p in posts[:K_XHS]:
  97. cid = p["id"]
  98. try:
  99. post = fetch_post_detail(cid, settings=settings, rate_limiter=detail_rl)
  100. except Exception:
  101. post = None
  102. base, pub = DATA / "search" / "xiaohongshu" / cid, f"/data/search/xiaohongshu/{cid}"
  103. srcs = _dedup((post.image_urls if post else None) or ([p["cover_url"]] if p.get("cover_url") else []))
  104. imgs = []
  105. for i, u in enumerate(srcs):
  106. local = _dl(u, "xiaohongshu", base / f"img_{i}.jpg", f"{pub}/img_{i}.jpg")
  107. if local:
  108. imgs.append(local)
  109. if not imgs:
  110. continue # 一张都没下来就丢(链接全失效)
  111. recs.append({"title": (post.title if post and post.title else p["title"]),
  112. "url": p["url"], "nick": p["nick_name"],
  113. "body_text": (post.body_text if post else ""),
  114. "cover": imgs[0], "images": imgs})
  115. return {"ok": recs, "error": None if recs else "详情/下载均失败"}
  116. def _prev_douyin() -> dict:
  117. """抖音关闭时,从上次的 search_results.json 沿用抖音结果(按 query 文本取),保住已抓到的视频。"""
  118. if not OUT.exists():
  119. return {}
  120. try:
  121. prev = json.loads(OUT.read_text("utf-8"))
  122. except Exception:
  123. return {}
  124. return {r["query"]: r.get("douyin") for r in prev if r.get("query")}
  125. def main() -> None:
  126. settings = Settings.from_env()
  127. demo = json.loads(DEMO.read_text("utf-8"))
  128. # 三平台搜索各自独立闸(随机 10~12s);抖音详情更轻(3~5s)
  129. dy_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  130. dy_detail = RateLimiter(min_interval_seconds=3.0, max_interval_seconds=5.0)
  131. wx_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  132. xhs_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX)
  133. xhs_detail = RateLimiter(min_interval_seconds=3.0, max_interval_seconds=5.0) # 小红书详情(拉完整帖)
  134. prev_dy = {} if DOUYIN_ON else _prev_douyin()
  135. if not DOUYIN_ON:
  136. print(f"(抖音已关闭:沿用上次 {len(prev_dy)} 条 query 的抖音结果,本次只跑微信+小红书)")
  137. results = []
  138. for tac in ("tactic1", "tactic2", "tactic4"):
  139. t = demo.get(tac) or {}
  140. name = t.get("name", tac)
  141. for it in (t.get("items") or [])[:N]:
  142. q = it.get("query")
  143. if not q:
  144. continue
  145. print(f"[{name}] {q}")
  146. if DOUYIN_ON:
  147. dy = douyin_topk(q, settings, dy_search, dy_detail)
  148. else:
  149. dy = prev_dy.get(q) or {"ok": [], "error": "抖音限流暂停,未跑"}
  150. wx = weixin_topk(q, settings, wx_search)
  151. xhs = xiaohongshu_topk(q, settings, xhs_search, xhs_detail)
  152. xhs_imgs = sum(len(r.get("images") or []) for r in xhs["ok"])
  153. print(f" 抖音 {len(dy['ok'])} 视频{_err(dy)}"
  154. f" 微信 {len(wx['ok'])} 封面{_err(wx)}"
  155. f" 小红书 {len(xhs['ok'])} 帖/{xhs_imgs} 图{_err(xhs)}")
  156. results.append({"method": name, "query": q, "douyin": dy, "weixin": wx, "xiaohongshu": xhs})
  157. OUT.parent.mkdir(parents=True, exist_ok=True)
  158. OUT.write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
  159. tot = lambda k: sum(len(r[k]["ok"]) for r in results)
  160. print(f"\nwrote {len(results)} 条 query(抖音 {tot('douyin')} / 微信 {tot('weixin')} / "
  161. f"小红书 {tot('xiaohongshu')})→ {OUT}")
  162. def _err(d: dict) -> str:
  163. return " / " + d["error"] if d.get("error") else ""
  164. if __name__ == "__main__":
  165. main()