|
@@ -45,6 +45,8 @@ RECORD_DIR = os.path.join(XHS_DIR, "_数据追踪")
|
|
|
# XiaohongshuSkills 库路径
|
|
# XiaohongshuSkills 库路径
|
|
|
XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
|
|
XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
|
|
|
sys.path.insert(0, XHS_SKILLS_PATH)
|
|
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"
|
|
CREATOR_HOME = "https://creator.xiaohongshu.com/new/home"
|
|
|
NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/new/note-manager"
|
|
NOTES_MANAGE_URL = "https://creator.xiaohongshu.com/new/note-manager"
|
|
@@ -167,50 +169,135 @@ def collect_overview(publisher) -> dict:
|
|
|
return overview
|
|
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):
|
|
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 = []
|
|
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]
|
|
return notes[:limit]
|
|
|
|
|
|
|
|
|
|
|