Kaynağa Gözat

feat(xhs): 日报采集点赞/收藏/分享字段,统计未回复评论数

Sisyphus Agent 2 hafta önce
ebeveyn
işleme
cafa9c194e

+ 3 - 0
运营文案/xhs_unreplied_comments.py

@@ -102,6 +102,9 @@ def _parse_posted_body(body_text: str) -> list:
             "title": str(n.get("display_title") or "(无标题)").strip(),
             "comments_count": int(n.get("comments_count") or 0),
             "view_count": int(n.get("view_count") or 0),
+            "likes": int(n.get("likes") or 0),
+            "collected_count": int(n.get("collected_count") or 0),
+            "shared_count": int(n.get("shared_count") or 0),
             "xsec_token": str(n.get("xsec_token") or "").strip(),
             "publish_time": str(n.get("time") or "").strip(),
         })

+ 127 - 40
运营文案/小红书发布/_过程脚本/xhs_daily_report.py

@@ -45,6 +45,8 @@ RECORD_DIR = os.path.join(XHS_DIR, "_数据追踪")
 # XiaohongshuSkills 库路径
 XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
 sys.path.insert(0, XHS_SKILLS_PATH)
+if OPERATION_DIR not in sys.path:
+    sys.path.insert(0, OPERATION_DIR)
 
 CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
 NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/new/note-manager"
@@ -167,50 +169,135 @@ def collect_overview(publisher) -> dict:
     return overview
 
 
+def _count_unreplied_comments(publisher, note_id: str, xsec_token: str) -> int:
+    """打开笔记详情页读取评论,统计未回复的父评论数。
+
+    判定规则:父评论下所有楼中楼(subComments)中,
+    若没有 userId 等于作者 userId 的子评论 → 计为未回复。
+    """
+    if not xsec_token or not note_id:
+        return 0
+    try:
+        res = publisher.get_feed_detail(
+            feed_id=note_id,
+            xsec_token=xsec_token,
+            load_all_comments=True,
+            limit=60,
+            click_more_replies=True,
+            reply_limit=10,
+        )
+    except Exception as e:
+        _log(f"  [WARN] 笔记 {note_id} 详情页读取失败: {e}")
+        return 0
+    detail = res.get("detail") or {}
+    note_obj = detail.get("note")
+    # note.user 可能是 dict(图文笔记)或 str(视频笔记的 userId 字段)
+    user_obj = (note_obj or {}).get("user") if isinstance(note_obj, dict) else None
+    if isinstance(user_obj, str):
+        author_id = user_obj
+    elif isinstance(user_obj, dict):
+        author_id = str(user_obj.get("userId") or "")
+    else:
+        author_id = ""
+    if not author_id:
+        return 0
+    comments = (detail.get("comments") or {}).get("list") or []
+    unreplied = 0
+    for c in comments:
+        subs = c.get("subComments") or []
+        replied = any(
+            str((s.get("userInfo") or {}).get("userId") or "") == author_id
+            for s in subs
+        )
+        if not replied:
+            unreplied += 1
+    return unreplied
+
+
 def collect_notes(publisher, limit=10):
     """
-    从笔记管理页(/new/note-manager)采集笔记列表。
-
-    该页每篇笔记在 innerText 中呈现为固定行块:
-        标题
-        YYYY-MM-DD HH:MM
-        阅读数
-        点赞数
-        收藏数
-        评论数
-        分享数
+    从笔记管理页采集笔记列表,并统计每篇未回复评论数。
+
+    优先使用 posted API(含 xsec_token),失败时退回 innerText 解析。
+    对每篇有评论的笔记打开详情页,统计作者尚未回复的父评论数。
     """
-    text = publisher._evaluate("document.body.innerText") or ""
-    lines = [ln.strip() for ln in text.split("\n")]
-    dates = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")
+    # ── 优先:posted API(精确数据 + xsec_token) ──────────────────────
+    raw_notes = []
+    try:
+        from xhs_unreplied_comments import capture_posted_notes
+        raw_notes = capture_posted_notes(publisher, max_wait=20.0)
+        _log(f"  [posted] 捕获 {len(raw_notes)} 篇笔记")
+    except Exception as e:
+        _log(f"  [WARN] posted API 捕获失败,退回 innerText 解析: {e}")
+        raw_notes = []
+
     notes = []
-    i = 0
-    while i < len(lines) and len(notes) < limit:
-        ln = lines[i]
-        # 日期行:上一行是标题,后续连续 5 行是数字
-        if dates.match(ln) and i + 5 < len(lines):
-            title = _clean_title(lines[i - 1]) if i >= 1 else ""
-            nums = []
-            # 收集日期行后连续的数字行
-            j = i + 1
-            while j < len(lines) and len(nums) < 5 and lines[j].isdigit():
-                nums.append(int(lines[j]))
-                j += 1
-            if len(nums) >= 5 and title:
-                # 页面顺序: 阅读/评论/点赞/收藏/分享
-                notes.append({
-                    "title": title,
-                    "publish_date": ln[:10],
-                    "read_count": nums[0],
-                    "comment_count": nums[1],
-                    "like_count": nums[2],
-                    "collect_count": nums[3],
-                    "share_count": nums[4],
-                    "unreplied_comment_count": 0,
-                })
-            i = j if j > i + 1 else i + 1
-        else:
-            i += 1
+    if raw_notes:
+        for n in raw_notes[:limit]:
+            entry = {
+                "title": n.get("title", ""),
+                "publish_date": (n.get("publish_time") or "")[:10],
+                "read_count": n.get("view_count", 0),
+                "comment_count": n.get("comments_count", 0),
+                "like_count": n.get("likes", 0),
+                "collect_count": n.get("collected_count", 0),
+                "share_count": n.get("shared_count", 0),
+                "unreplied_comment_count": 0,
+                "_xsec_token": n.get("xsec_token", ""),
+                "_note_id": n.get("id", ""),
+            }
+            notes.append(entry)
+    else:
+        # fallback:innerText 解析(原逻辑,无法获取 xsec_token,unreplied 固定为 0)
+        text = publisher._evaluate("document.body.innerText") or ""
+        lines = [ln.strip() for ln in text.split("\n")]
+        dates = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")
+        i = 0
+        while i < len(lines) and len(notes) < limit:
+            ln = lines[i]
+            if dates.match(ln) and i + 5 < len(lines):
+                title = _clean_title(lines[i - 1]) if i >= 1 else ""
+                nums = []
+                j = i + 1
+                while j < len(lines) and len(nums) < 5 and lines[j].isdigit():
+                    nums.append(int(lines[j]))
+                    j += 1
+                if len(nums) >= 5 and title:
+                    notes.append({
+                        "title": title,
+                        "publish_date": ln[:10],
+                        "read_count": nums[0],
+                        "comment_count": nums[1],
+                        "like_count": nums[2],
+                        "collect_count": nums[3],
+                        "share_count": nums[4],
+                        "unreplied_comment_count": 0,
+                        "_xsec_token": "",
+                        "_note_id": "",
+                    })
+                i = j if j > i + 1 else i + 1
+            else:
+                i += 1
+
+    # ── 逐篇统计未回复评论 ─────────────────────────────────────────────
+    for entry in notes:
+        if entry["comment_count"] <= 0:
+            continue
+        unreplied = _count_unreplied_comments(
+            publisher, entry["_note_id"], entry["_xsec_token"]
+        )
+        entry["unreplied_comment_count"] = unreplied
+        _log(
+            f"  [笔记] {entry['title'][:30]}  "
+            f"评论={entry['comment_count']} "
+            f"未回复={unreplied}"
+        )
+
+    # 清理内部字段(保持对外接口干净)
+    for entry in notes:
+        entry.pop("_xsec_token", None)
+        entry.pop("_note_id", None)
+
     return notes[:limit]