"""用生成的 query 真实搜帖子/视频(抖音 + 微信公众号 + 小红书),媒体存本地 data/。 每法取前 N 条 query,每条 query 每个渠道取 top-K: 抖音:search(top-1) → detail → 下载视频+封面(搜索/详情各自限速) 微信公众号:search(top-K) → 下载每个封面(封面在搜索回包里,无需 detail) 小红书:search(top-K) → 下载每个封面(封面在 note_card 里,无需 detail;视频帖无直链) 限流:三个平台的「搜索」各自独立闸,两次搜索间隔随机 10~12s;抖音「详情」走更轻的 3~5s 闸。 产出每条记录:{method, query, douyin/weixin/xiaohongshu:{ok:[...],error}}。 用法:PYTHONPATH=. python scripts/run_search.py """ from __future__ import annotations import hashlib import json from pathlib import Path from acquisition.crawler import RateLimiter, fetch_post_detail from acquisition.search import search_keyword, search_weixin, search_xiaohongshu from core.config import Settings from creation_knowledge.integrations.video_extract import _default_download ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" DEMO = DATA / "queries" / "demo.json" OUT = DATA / "queries" / "search_results.json" DOUYIN_ON = True # 抖音走 piaoquantv,真跑 N = 6 # 每法取前 N 条 query K_DOUYIN = 3 # 抖音每条 query 取前 3 个视频(每个要 1 详情 + 下 mp4,最重,故取少) K_WEIXIN = 10 # 微信公众号每条 query 取前 10 个封面 K_XHS = 10 # 小红书每条 query 取前 10 帖(逐帖下完整图+正文) SEARCH_MIN, SEARCH_MAX = 10.0, 12.0 # 每平台两次搜索之间随机间隔(秒) def _dl(url: str, platform: str, dst: Path, public: str): """下载到 dst,返回公开路径;失败返回 None。""" try: dst.parent.mkdir(parents=True, exist_ok=True) dst.write_bytes(_default_download(url, platform)) return public except Exception: return None def douyin_topk(query: str, settings: Settings, search_rl: RateLimiter, detail_rl: RateLimiter) -> dict: """抖音搜 top-K 视频:返回 {ok:[{title,url,cover,video}], error}。搜索/详情各走各的闸。""" try: ids = search_keyword(query, platform="douyin", content_type="视频", limit=K_DOUYIN, settings=settings, rate_limiter=search_rl) except Exception as exc: return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"} if not ids: return {"ok": [], "error": "未搜到"} recs = [] for vid in ids[:K_DOUYIN]: try: post = fetch_post_detail(vid, settings=settings, rate_limiter=detail_rl) except Exception: continue base, pub = DATA / "search" / "douyin" / post.id, f"/data/search/douyin/{post.id}" rec = {"title": post.title, "url": post.url, "cover": None, "video": None} if post.image_urls: rec["cover"] = _dl(post.image_urls[0], "douyin", base / "cover.jpg", pub + "/cover.jpg") if post.video_urls: rec["video"] = _dl(post.video_urls[0], "douyin", base / "video.mp4", pub + "/video.mp4") if rec["video"] or rec["cover"]: recs.append(rec) return {"ok": recs, "error": None if recs else "详情/下载均失败"} def weixin_topk(query: str, settings: Settings, rl: RateLimiter) -> dict: """微信公众号搜 top-K 文章封面:返回 {ok:[{title,url,nick,cover}], error}。""" try: arts = search_weixin(query, limit=K_WEIXIN, settings=settings, rate_limiter=rl) except Exception as exc: return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"} if not arts: return {"ok": [], "error": "未搜到"} recs = [] for a in arts[:K_WEIXIN]: h = hashlib.md5(a["url"].encode()).hexdigest()[:16] base, pub = DATA / "search" / "weixin" / h, f"/data/search/weixin/{h}" rec = {"title": a["title"], "url": a["url"], "nick": a["nick_name"], "cover": None} if a["cover_url"]: rec["cover"] = _dl(a["cover_url"], "weixin", base / "cover.jpg", pub + "/cover.jpg") recs.append(rec) return {"ok": recs, "error": None} def _dedup(urls): seen, out = set(), [] for u in urls: if u and u not in seen: seen.add(u) out.append(u) return out def xiaohongshu_topk(query: str, settings: Settings, search_rl: RateLimiter, detail_rl: RateLimiter) -> dict: """小红书搜 top-K → 逐条调详情拉【完整帖子】:下载全部图片 + 存正文到本地。 小红书链接会被反爬封,必须把整帖存档,不能只留封面+链接。返回 {ok:[{title,url,nick,cover,images,body_text}], error}。""" try: posts = search_xiaohongshu(query, content_type="图文", limit=K_XHS, settings=settings, rate_limiter=search_rl) except Exception as exc: return {"ok": [], "error": f"搜索失败: {str(exc)[:50]}"} if not posts: return {"ok": [], "error": "未搜到"} recs = [] for p in posts[:K_XHS]: cid = p["id"] try: post = fetch_post_detail(cid, settings=settings, rate_limiter=detail_rl) except Exception: post = None base, pub = DATA / "search" / "xiaohongshu" / cid, f"/data/search/xiaohongshu/{cid}" srcs = _dedup((post.image_urls if post else None) or ([p["cover_url"]] if p.get("cover_url") else [])) imgs = [] for i, u in enumerate(srcs): local = _dl(u, "xiaohongshu", base / f"img_{i}.jpg", f"{pub}/img_{i}.jpg") if local: imgs.append(local) if not imgs: continue # 一张都没下来就丢(链接全失效) recs.append({"title": (post.title if post and post.title else p["title"]), "url": p["url"], "nick": p["nick_name"], "body_text": (post.body_text if post else ""), "cover": imgs[0], "images": imgs}) return {"ok": recs, "error": None if recs else "详情/下载均失败"} def _prev_douyin() -> dict: """抖音关闭时,从上次的 search_results.json 沿用抖音结果(按 query 文本取),保住已抓到的视频。""" if not OUT.exists(): return {} try: prev = json.loads(OUT.read_text("utf-8")) except Exception: return {} return {r["query"]: r.get("douyin") for r in prev if r.get("query")} def main() -> None: settings = Settings.from_env() demo = json.loads(DEMO.read_text("utf-8")) # 三平台搜索各自独立闸(随机 10~12s);抖音详情更轻(3~5s) dy_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX) dy_detail = RateLimiter(min_interval_seconds=3.0, max_interval_seconds=5.0) wx_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX) xhs_search = RateLimiter(min_interval_seconds=SEARCH_MIN, max_interval_seconds=SEARCH_MAX) xhs_detail = RateLimiter(min_interval_seconds=3.0, max_interval_seconds=5.0) # 小红书详情(拉完整帖) prev_dy = {} if DOUYIN_ON else _prev_douyin() if not DOUYIN_ON: print(f"(抖音已关闭:沿用上次 {len(prev_dy)} 条 query 的抖音结果,本次只跑微信+小红书)") results = [] for tac in ("tactic1", "tactic2", "tactic4"): t = demo.get(tac) or {} name = t.get("name", tac) for it in (t.get("items") or [])[:N]: q = it.get("query") if not q: continue print(f"[{name}] {q}") if DOUYIN_ON: dy = douyin_topk(q, settings, dy_search, dy_detail) else: dy = prev_dy.get(q) or {"ok": [], "error": "抖音限流暂停,未跑"} wx = weixin_topk(q, settings, wx_search) xhs = xiaohongshu_topk(q, settings, xhs_search, xhs_detail) xhs_imgs = sum(len(r.get("images") or []) for r in xhs["ok"]) print(f" 抖音 {len(dy['ok'])} 视频{_err(dy)}" f" 微信 {len(wx['ok'])} 封面{_err(wx)}" f" 小红书 {len(xhs['ok'])} 帖/{xhs_imgs} 图{_err(xhs)}") results.append({"method": name, "query": q, "douyin": dy, "weixin": wx, "xiaohongshu": xhs}) OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8") tot = lambda k: sum(len(r[k]["ok"]) for r in results) print(f"\nwrote {len(results)} 条 query(抖音 {tot('douyin')} / 微信 {tot('weixin')} / " f"小红书 {tot('xiaohongshu')})→ {OUT}") def _err(d: dict) -> str: return " / " + d["error"] if d.get("error") else "" if __name__ == "__main__": main()