xhs_unreplied_comments.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. """小红书评论检测脚本 - 记录每篇笔记评论数,发现新增评论时读取具体评论并推送给回复建议
  2. 运行逻辑:
  3. 1. CDP 网络捕获 note-manager 的 posted API,获取每篇笔记的 id/xsec_token/comments_count/标题
  4. 2. 与状态文件 xhs_comment_state.json 对比: 评论数比上次增加的笔记 → 打开详情页读取评论区
  5. 3. 用 createTime > last_check_time 识别新增评论(含楼中楼回复)
  6. 4. 每条新评论用 opencode 生成针对性回复建议
  7. 5. 推送飞书(逐条评论+建议); 无新增评论则不推送
  8. 6. 更新状态文件。首次运行只建立基线, 不推送
  9. """
  10. import argparse
  11. import datetime
  12. import io
  13. import json
  14. import os
  15. import re
  16. import sys
  17. import time
  18. import requests
  19. if not sys.stdout.isatty():
  20. try:
  21. sys.stdout.reconfigure(encoding='utf-8', errors='replace')
  22. except Exception:
  23. pass
  24. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  25. OPERATION_DIR = SCRIPT_DIR
  26. CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
  27. STATE_PATH = os.path.join(OPERATION_DIR, "xhs_comment_state.json")
  28. XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
  29. sys.path.insert(0, XHS_SKILLS_PATH)
  30. sys.path.insert(0, OPERATION_DIR)
  31. from cdp_publish import XiaohongshuPublisher
  32. from xhs_feishu import _get_token, FEISHU_MSG_URL
  33. import opencode_client
  34. POSTED_API_PATH = "/api/galaxy/v2/creator/note/user/posted"
  35. NOTE_MANAGER_URL = "https://creator.xiaohongshu.com/new/note-manager"
  36. FALLBACK_SUGGESTIONS = [
  37. "谢谢你这么用心地评论,看到你的留言真的很暖心~",
  38. "哈哈哈你这个角度很特别,欢迎常来聊~",
  39. "你的想法很有意思,我记下了,后面单独展开说说",
  40. "谢谢支持!你提的这个点我改天专门写一篇",
  41. "被你说中了,看来咱们想到一块儿去了 😄",
  42. ]
  43. def _load_config():
  44. with open(CONFIG_PATH, "r", encoding="utf-8") as f:
  45. return json.load(f)
  46. def _load_state():
  47. if not os.path.exists(STATE_PATH):
  48. return {"notes": {}}
  49. try:
  50. with open(STATE_PATH, "r", encoding="utf-8") as f:
  51. return json.load(f)
  52. except Exception:
  53. return {"notes": {}}
  54. def _save_state(state):
  55. tmp = STATE_PATH + ".tmp"
  56. with open(tmp, "w", encoding="utf-8") as f:
  57. json.dump(state, f, ensure_ascii=False, indent=1)
  58. os.replace(tmp, STATE_PATH)
  59. def _now_ms() -> int:
  60. return int(time.time() * 1000)
  61. def _fmt_ms(ms: int) -> str:
  62. try:
  63. return datetime.datetime.fromtimestamp(ms / 1000).strftime("%m-%d %H:%M")
  64. except Exception:
  65. return ""
  66. def _parse_posted_body(body_text: str) -> list:
  67. """解析 posted API 响应, 返回笔记列表。"""
  68. try:
  69. payload = json.loads(body_text)
  70. except json.JSONDecodeError:
  71. return []
  72. notes = ((payload.get("data") or {}).get("notes")) or []
  73. result = []
  74. for n in notes:
  75. if not isinstance(n, dict):
  76. continue
  77. note_id = str(n.get("id") or "").strip()
  78. if not note_id:
  79. continue
  80. result.append({
  81. "id": note_id,
  82. "title": str(n.get("display_title") or "(无标题)").strip(),
  83. "comments_count": int(n.get("comments_count") or 0),
  84. "view_count": int(n.get("view_count") or 0),
  85. "likes": int(n.get("likes") or 0),
  86. "collected_count": int(n.get("collected_count") or 0),
  87. "shared_count": int(n.get("shared_count") or 0),
  88. "xsec_token": str(n.get("xsec_token") or "").strip(),
  89. "publish_time": str(n.get("time") or "").strip(),
  90. })
  91. return result
  92. def capture_posted_notes(publisher, max_wait: float = 30.0) -> list:
  93. """通过 CDP 网络捕获 note-manager 的 posted API, 滚动加载全部笔记。
  94. 注意: 滚动必须用裸 ws.send 发 Runtime.evaluate, 不能走 publisher._evaluate
  95. (其内部 _send 会 recv 吞掉网络事件, 导致漏抓后续页面的 API 响应)。
  96. """
  97. publisher._send("Page.enable")
  98. publisher._send("Network.enable", {"maxPostDataSize": 65536})
  99. publisher._navigate(NOTE_MANAGER_URL)
  100. time.sleep(3.0)
  101. request_url_by_id = {}
  102. target_request_ids = set()
  103. start = time.time()
  104. last_scroll_at = 0.0
  105. last_new_at = time.time()
  106. _scroll_cmd_id = 9000
  107. while time.time() - start < max_wait:
  108. # 捕获到请求后空闲 5s → 提前结束
  109. if target_request_ids and time.time() - last_new_at > 5.0:
  110. break
  111. timeout = min(0.8, max(0.1, max_wait - (time.time() - start)))
  112. try:
  113. raw = publisher.ws.recv(timeout=timeout)
  114. except TimeoutError:
  115. # 空闲期滚动触发下一页加载(裸 ws.send, 不 recv)
  116. if time.time() - last_scroll_at > 2.5:
  117. _scroll_cmd_id += 1
  118. publisher.ws.send(json.dumps({
  119. "id": _scroll_cmd_id,
  120. "method": "Runtime.evaluate",
  121. "params": {"expression": "window.scrollTo(0, document.body.scrollHeight)"},
  122. }))
  123. last_scroll_at = time.time()
  124. continue
  125. try:
  126. message = json.loads(raw)
  127. except json.JSONDecodeError:
  128. continue
  129. method = message.get("method")
  130. params = message.get("params", {})
  131. if method == "Network.requestWillBeSent":
  132. rid = params.get("requestId")
  133. req = params.get("request", {})
  134. if isinstance(rid, str):
  135. request_url_by_id[rid] = req.get("url", "")
  136. elif method == "Network.responseReceived":
  137. rid = params.get("requestId")
  138. if not isinstance(rid, str):
  139. continue
  140. url = request_url_by_id.get(rid, "")
  141. if POSTED_API_PATH not in url:
  142. continue
  143. if params.get("response", {}).get("status") != 200:
  144. continue
  145. if rid not in target_request_ids:
  146. target_request_ids.add(rid)
  147. last_new_at = time.time()
  148. # 空闲太久且已无新请求 → 停止
  149. if not target_request_ids:
  150. raise RuntimeError("未捕获到笔记列表 API 响应")
  151. notes_by_id = {}
  152. for rid in target_request_ids:
  153. body_result = publisher._send("Network.getResponseBody", {"requestId": rid})
  154. body_text = body_result.get("body", "")
  155. if body_result.get("base64Encoded"):
  156. import base64
  157. body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")
  158. for n in _parse_posted_body(body_text):
  159. notes_by_id[n["id"]] = n
  160. notes = list(notes_by_id.values())
  161. print(f"[capture] pages={len(target_request_ids)} notes={len(notes)}")
  162. return notes
  163. def _norm_comment(c: dict, is_sub: bool) -> dict:
  164. """规范化一条评论(父评论或楼中楼)。"""
  165. content = str(c.get("content") or "").strip()
  166. pictures = c.get("pictures") or []
  167. if not content and pictures:
  168. content = "[图片]"
  169. user = c.get("userInfo") or {}
  170. create_ms = int(c.get("createTime") or 0)
  171. return {
  172. "id": str(c.get("id") or "").strip(),
  173. "content": content,
  174. "nickname": str(user.get("nickname") or "").strip() or "匿名用户",
  175. "create_ms": create_ms,
  176. "is_sub": is_sub,
  177. }
  178. def fetch_note_comments(publisher, note_id: str, xsec_token: str) -> list:
  179. """打开笔记详情页读取评论(父评论+楼中楼)。"""
  180. if not xsec_token:
  181. return []
  182. res = publisher.get_feed_detail(
  183. feed_id=note_id,
  184. xsec_token=xsec_token,
  185. load_all_comments=True,
  186. limit=60,
  187. click_more_replies=True,
  188. reply_limit=10,
  189. )
  190. detail = res.get("detail") or {}
  191. comments_raw = detail.get("comments") or {}
  192. parents = comments_raw.get("list") or []
  193. out = []
  194. for p in parents:
  195. if not isinstance(p, dict):
  196. continue
  197. out.append(_norm_comment(p, is_sub=False))
  198. for s in (p.get("subComments") or []):
  199. if isinstance(s, dict):
  200. out.append(_norm_comment(s, is_sub=True))
  201. return out
  202. def generate_suggestions(title: str, new_comments: list) -> list:
  203. """用 opencode 批量生成每条新评论的回复建议; 失败则回退模板。"""
  204. lines = []
  205. for i, c in enumerate(new_comments, 1):
  206. lines.append(f"{i}. 用户「{c.get('nickname')}」:{c.get('content') or '(图片)'}")
  207. prompt = (
  208. "你是小红书博主「72年冻龄妈妈的家的算法」(家庭教育博主) 的运营助手。\n"
  209. f"笔记《{title}》收到以下新评论({len(lines)}条),请为每一条写一条中文回复建议。\n"
  210. "要求: 每条 15-40 字, 口语化、真诚、像真人博主, 避免套话和模板感, 可适当互动引导。\n\n"
  211. "评论列表:\n" + "\n".join(lines) +
  212. "\n\n请严格按以下格式输出, 每条一行:\n1. <回复内容>\n2. <回复内容>\n..."
  213. )
  214. try:
  215. reply = opencode_client.ask("xhs-comment-suggest", prompt, timeout=120)
  216. except Exception as e:
  217. print(f"[suggest] opencode 失败, 使用模板回退: {e}")
  218. return [FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)] for i in range(len(new_comments))]
  219. parsed = []
  220. for ln in reply.splitlines():
  221. ln = ln.strip()
  222. m = re.match(r"^(\d+)\s*[.、)::]\s*(.+)$", ln)
  223. if m and m.group(2).strip():
  224. parsed.append(m.group(2).strip())
  225. if not parsed:
  226. # 严格格式未命中, 按非空行盲取
  227. parsed = [ln.strip() for ln in reply.splitlines() if ln.strip()]
  228. if len(parsed) < len(new_comments):
  229. for i in range(len(parsed), len(new_comments)):
  230. parsed.append(FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)])
  231. return parsed[:len(new_comments)]
  232. def push_feishu(cfg, text: str):
  233. token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
  234. url = f"{FEISHU_MSG_URL}?receive_id_type=chat_id"
  235. r = requests.post(
  236. url,
  237. headers={"Authorization": f"Bearer {token}"},
  238. json={
  239. "receive_id": cfg["feishu_chat_id"],
  240. "msg_type": "text",
  241. "content": json.dumps({"text": text}, ensure_ascii=False),
  242. },
  243. timeout=20,
  244. )
  245. d = r.json()
  246. if d.get("code") != 0:
  247. raise RuntimeError(f"推送失败: {d.get('msg')}")
  248. return True
  249. def check_comments(dry_run: bool = False):
  250. """检查新增评论并推送飞书"""
  251. cfg = _load_config()
  252. state = _load_state()
  253. notes_state = state.setdefault("notes", {})
  254. publisher = XiaohongshuPublisher()
  255. publisher.connect(reuse_existing_tab=True)
  256. try:
  257. if not publisher.check_login():
  258. raise RuntimeError("未登录小红书")
  259. notes = capture_posted_notes(publisher)
  260. now = _now_ms()
  261. first_run = not notes_state
  262. changed = []
  263. for n in notes:
  264. nid = n["id"]
  265. prev = notes_state.get(nid)
  266. if prev is None:
  267. # 新笔记: 建立基线
  268. notes_state[nid] = {
  269. "title": n["title"],
  270. "comment_count": n["comments_count"],
  271. "last_check_time": now,
  272. }
  273. continue
  274. prev_count = int(prev.get("comment_count") or 0)
  275. if n["comments_count"] <= prev_count:
  276. continue
  277. # 评论数增加 → 打开评论区
  278. try:
  279. comments = fetch_note_comments(publisher, nid, n.get("xsec_token") or "")
  280. except Exception as e:
  281. print(f"[fetch] 笔记 {nid} 评论区读取失败, 跳过: {e}")
  282. continue
  283. last_check_ms = int(prev.get("last_check_time") or 0)
  284. new = [c for c in comments if c["create_ms"] > last_check_ms and c["id"]]
  285. if not new:
  286. # 评论被删除/替换, 只更新计数
  287. notes_state[nid]["comment_count"] = n["comments_count"]
  288. continue
  289. suggestions = generate_suggestions(n["title"], new)
  290. for i, c in enumerate(new):
  291. c["suggestion"] = suggestions[i] if i < len(suggestions) else FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)]
  292. changed.append({
  293. "note": n,
  294. "new_comments": new,
  295. })
  296. # 更新基线: 计数 + 已读时间(取已读评论的最大时间, 防止漏报)
  297. max_create = max(c["create_ms"] for c in new)
  298. notes_state[nid]["comment_count"] = n["comments_count"]
  299. notes_state[nid]["last_check_time"] = max(now, max_create)
  300. notes_state[nid]["title"] = n["title"]
  301. _save_state(state)
  302. if first_run:
  303. print("[INIT] 首次运行: 已建立全部笔记基线, 不推送")
  304. return
  305. if not changed:
  306. print("[SKIP] 无新增评论, 不推送")
  307. return
  308. date = time.strftime("%Y-%m-%d %H:%M")
  309. sections = []
  310. for ch in changed:
  311. note = ch["note"]
  312. new_c = ch["new_comments"]
  313. lines = [f"《{note['title']}》 评论 +{len(new_c)} (共 {note['comments_count']} 条)"]
  314. if note.get("publish_time"):
  315. lines[0] += f" | 发布 {note['publish_time']}"
  316. for c in new_c:
  317. kind = "回复" if c.get("is_sub") else "评论"
  318. lines.append(
  319. f"· [{kind}] @{c.get('nickname')} ({_fmt_ms(c.get('create_ms'))})\n"
  320. f" {c.get('content') or '(图片)'}\n"
  321. f" 💡 建议回复: {c.get('suggestion')}"
  322. )
  323. sections.append("\n".join(lines))
  324. msg = (
  325. f"📬 小红书新增评论提醒({date})\n\n"
  326. + "\n\n".join(sections)
  327. + "\n\n💬 提示:以上为新增评论,请及时回复以增加互动和粉丝粘性"
  328. )
  329. print(msg)
  330. if dry_run:
  331. print("[DRY-RUN] 未实际推送")
  332. return
  333. push_feishu(cfg, msg)
  334. print("[OK] 评论通知已推送")
  335. except Exception as e:
  336. print(f"检查评论失败: {e}")
  337. import traceback
  338. traceback.print_exc()
  339. finally:
  340. try:
  341. publisher.disconnect()
  342. except Exception:
  343. pass
  344. if __name__ == "__main__":
  345. parser = argparse.ArgumentParser(description="小红书新增评论检测")
  346. parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
  347. args = parser.parse_args()
  348. check_comments(dry_run=args.dry_run)