| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- """用生成的 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()
|