Sfoglia il codice sorgente

feat(search):小红书改下载完整帖子(全部图片+正文)

小红书链接会被反爬封,只存封面+链接以后打不开。故 xiaohongshu_topk 改为逐帖调详情,
下载全部图片到 data/search/xiaohongshu/<id>/img_*.jpg + 存正文;store 的 extra 带上
images/body_text。小红书详情走独立 3~5s 闸。抖音 DOUYIN_ON=True(piaoquantv 真跑)。
取数规模:抖音每条 3(要下 mp4 最重)、微信/小红书每条 10。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee 2 settimane fa
parent
commit
d8f1204e79
2 ha cambiato i file con 42 aggiunte e 16 eliminazioni
  1. 1 1
      acquisition/store.py
  2. 41 15
      scripts/run_search.py

+ 1 - 1
acquisition/store.py

@@ -116,7 +116,7 @@ def _flatten_search(rec: dict) -> list[dict]:
         hits = v.get("ok") or []
         if hits:
             for h in hits:
-                extra = {k: h[k] for k in ("nick",) if k in h}
+                extra = {k: h[k] for k in ("nick", "images", "body_text") if k in h}
                 out.append({"platform": platform, "ok": 1, "title": h.get("title"),
                             "url": h.get("url"), "cover": h.get("cover"),
                             "video": h.get("video"), "extra": extra})

+ 41 - 15
scripts/run_search.py

@@ -23,11 +23,11 @@ ROOT = Path(__file__).resolve().parent.parent
 DATA = ROOT / "data"
 DEMO = DATA / "queries" / "demo.json"
 OUT = DATA / "queries" / "search_results.json"
-DOUYIN_ON = False                       # 抖音限流暂停:False 时不发抖音请求,沿用上次结果里的抖音
+DOUYIN_ON = True                        # 抖音走 piaoquantv,真跑
 N = 6                                    # 每法取前 N 条 query
-K_DOUYIN = 1                             # 抖音每条 query 只取第 1 个视频(躲限流
-K_WEIXIN = 5                             # 微信公众号每条 query 取前 K 个封面
-K_XHS = 5                               # 小红书每条 query 取前 K 个封面
+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     # 每平台两次搜索之间随机间隔(秒)
 
 
@@ -86,23 +86,47 @@ def weixin_topk(query: str, settings: Settings, rl: RateLimiter) -> dict:
     return {"ok": recs, "error": None}
 
 
-def xiaohongshu_topk(query: str, settings: Settings, rl: RateLimiter) -> dict:
-    """小红书搜 top-K 帖子封面:返回 {ok:[{title,url,nick,cover}], error}。封面来自搜索回包,无需 detail。"""
+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=rl)
+                                   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]:
-        base, pub = DATA / "search" / "xiaohongshu" / p["id"], f"/data/search/xiaohongshu/{p['id']}"
-        rec = {"title": p["title"], "url": p["url"], "nick": p["nick_name"], "cover": None}
-        if p["cover_url"]:
-            rec["cover"] = _dl(p["cover_url"], "xiaohongshu", base / "cover.jpg", pub + "/cover.jpg")
-        recs.append(rec)
-    return {"ok": recs, "error": None}
+        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:
@@ -124,6 +148,7 @@ def main() -> None:
     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 的抖音结果,本次只跑微信+小红书)")
@@ -141,10 +166,11 @@ def main() -> None:
             else:
                 dy = prev_dy.get(q) or {"ok": [], "error": "抖音限流暂停,未跑"}
             wx = weixin_topk(q, settings, wx_search)
-            xhs = xiaohongshu_topk(q, settings, xhs_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'])} 封面{_err(xhs)}")
+                  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")