فهرست منبع

内容: 评论通知改造: 评论数增量检测+读取新评论+opencode生成针对性回复建议, 无新增不推送; 修复 xhs_feishu/opencode_client import 时劫持 stdout 导致输出关闭

Sisyphus Agent 2 هفته پیش
والد
کامیت
eb94b3cc61
3فایلهای تغییر یافته به همراه556 افزوده شده و 169 حذف شده
  1. 211 0
      运营文案/opencode_client.py
  2. 6 4
      运营文案/xhs_feishu.py
  3. 339 165
      运营文案/xhs_unreplied_comments.py

+ 211 - 0
运营文案/opencode_client.py

@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""
+opencode_client.py — 通过本机常驻 opencode 服务的 HTTP API 进行会话交互
+
+负责:
+    1. 认证(basic auth)
+    2. chat_id → session_id 映射的持久化(JSON 文件,每用户/群一个 opencode 会话)
+    3. 确保某 chat 的会话存在(build agent)
+    4. 发消息 + 轮询取 assistant 最终文本回复
+
+权限策略(最安全):
+    - 只为会话启用「只读」类工具(读文件、浏览、文本生成)
+    - 禁用运行命令 / 写文件等需要授权的操作
+    - 因此 opencode 只能做无副作用的分析、生成回复,无法改动本机文件
+"""
+import io
+import json
+import os
+import sys
+import time
+import urllib.parse
+import urllib.request
+import base64
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+OPERATION_DIR = SCRIPT_DIR
+CFC_ROOT = os.path.dirname(OPERATION_DIR)
+
+# opencode 常驻服务
+OPCODE_BASE = "http://127.0.0.1:4090"
+OPCODE_USER = "opencode"
+OPCODE_PASSWORD = os.environ.get("OPENCODE_SERVER_PASSWORD", "IwinTrue@123")
+
+# 会话映射文件(chat_id -> session_id)
+SESSION_MAP_PATH = os.path.join(OPERATION_DIR, "opencode_sessions.json")
+
+# opencode 会话工作目录(项目根,含素材库/脚本,open code 可直接只读分析)
+WORK_DIR = CFC_ROOT
+
+# build agent 默认工作于该 project 的模型取服务端默认
+AGENT = "build"
+
+# 只读工具白名单:其余工具(bash 执行、写文件等)一律禁用
+# 此处列出 opencode 内置常用只读工具;未列出的默认不可用(服务端按 tools 参数过滤)
+READONLY_TOOLS = []
+
+# 危险的需授权工具,显式禁用(防御性,即便服务端宽松也不给)
+FORBIDDEN_TOOLS = [
+    "bash", "shell", "write", "edit", "apply_patch",
+    "task", "dispatch", "webfetch_post", "chrome_launch",
+]
+
+
+def _auth_header() -> str:
+    token = base64.b64encode(
+        f"{OPCODE_USER}:{OPCODE_PASSWORD}".encode("utf-8")
+    ).decode("ascii")
+    return f"Basic {token}"
+
+
+def _api(method: str, path: str, body=None, timeout: float = 60):
+    """请求 opencode HTTP API,返回 (status, decoded_body)。"""
+    url = OPCODE_BASE + path
+    data = None
+    if body is not None:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+    req = urllib.request.Request(url, data=data, method=method)
+    req.add_header("Authorization", _auth_header())
+    if body is not None:
+        req.add_header("Content-Type", "application/json; charset=utf-8")
+    with urllib.request.urlopen(req, timeout=timeout) as resp:
+        raw = resp.read().decode("utf-8", errors="replace")
+        return resp.status, raw
+
+
+def _load_session_map() -> dict:
+    if not os.path.exists(SESSION_MAP_PATH):
+        return {}
+    try:
+        with open(SESSION_MAP_PATH, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception:
+        return {}
+
+
+def _save_session_map(mapping: dict):
+    with open(SESSION_MAP_PATH, "w", encoding="utf-8") as f:
+        json.dump(mapping, f, ensure_ascii=False, indent=2)
+
+
+def _create_session(chat_id: str) -> str:
+    """为某 chat 新建一个 build agent 会话,返回 session_id。"""
+    dir_q = urllib.parse.quote(WORK_DIR)
+    body = {
+        "title": f"xhs-bot-{chat_id[:24]}",
+        "agent": AGENT,
+    }
+    st, raw = _api("POST", f"/session?directory={dir_q}", body=body, timeout=30)
+    if st not in (200, 201):
+        raise RuntimeError(f"创建会话失败 status={st}: {raw[:300]}")
+    data = json.loads(raw)
+    sid = data.get("id")
+    if not sid:
+        raise RuntimeError(f"创建会话未返回 session_id: {raw[:300]}")
+    return sid
+
+
+def ensure_session(chat_id: str) -> str:
+    """确保某 chat 有对应 opencode 会话,返回 session_id。"""
+    mapping = _load_session_map()
+    sid = mapping.get(chat_id)
+    if sid:
+        return sid
+    sid = _create_session(chat_id)
+    mapping[chat_id] = sid
+    _save_session_map(mapping)
+    return sid
+
+
+def send_prompt(session_id: str, text: str, timeout: float = 30):
+    """向指定会话异步发送一条用户消息。返回 HTTP 状态。"""
+    parts = [{"type": "text", "text": text}]
+    body = {
+        "parts": parts,
+        # 只读约束:明确禁用需授权的执行/写入类工具
+        "tools": {t: False for t in FORBIDDEN_TOOLS},
+    }
+    st, raw = _api("POST", f"/session/{session_id}/prompt_async",
+                   body=body, timeout=timeout)
+    return st, raw
+
+
+def _extract_assistant_text(msgs) -> str:
+    """从 v1 message 列表里提取最后一条 assistant 的最终可见文本。"""
+    last = msgs[-1] if msgs else None
+    if not last:
+        return ""
+    if last.get("info", {}).get("role") != "assistant":
+        return ""
+    texts = [p.get("text", "") for p in last.get("parts", [])
+             if p.get("type") == "text" and p.get("text")]
+    return texts[-1] if texts else ""
+
+
+def wait_for_reply(session_id: str, timeout: float = 180,
+                   poll_interval: float = 5.0) -> str:
+    """等待会话最新 assistant 回复完成,返回最终文本。
+
+    处理两种情形:
+      1) assistant 回复极快,进入本函数时已生成 → 直接看最后一条 assistant 文本
+      2) 正常流式:等待「发消息后新增」的 assistant 带文本出现
+    """
+    # 进入时先记录当前消息条数,作为"已有"基线
+    try:
+        _, raw0 = _api("GET", f"/session/{session_id}/message", timeout=30)
+        before = len(json.loads(raw0)) if raw0 else 0
+    except Exception:
+        before = 0
+
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        time.sleep(poll_interval)
+        try:
+            st, raw = _api("GET", f"/session/{session_id}/message", timeout=30)
+            if st != 200 or not raw:
+                continue
+            msgs = json.loads(raw)
+        except Exception:
+            continue
+        if not msgs:
+            continue
+
+        # 情况1:最后一条是 assistant 且有文本(覆盖超快回复:before 已含该条)
+        last = msgs[-1]
+        if last.get("info", {}).get("role") == "assistant":
+            lt = [p.get("text", "") for p in last.get("parts", [])
+                  if p.get("type") == "text" and p.get("text")]
+            if lt:
+                return lt[-1]
+
+        # 情况2:出现新增的 assistant 消息(条数 > 基线)
+        if len(msgs) > before:
+            for m in reversed(msgs):
+                role = m.get("info", {}).get("role")
+                if role == "assistant":
+                    txts = [p.get("text", "") for p in m.get("parts", [])
+                            if p.get("type") == "text" and p.get("text")]
+                    if txts:
+                        return txts[-1]
+    raise TimeoutError(f"等待 opencode 回复超时({timeout}s)")
+
+
+def ask(chat_id: str, text: str, timeout: float = 180) -> str:
+    """高层封装:确保会话 → 发消息 → 等回复 → 返回最终文本。"""
+    sid = ensure_session(chat_id)
+    st, raw = send_prompt(sid, text)
+    if st != 204:
+        raise RuntimeError(f"发送到 opencode 失败 status={st}: {raw[:300]}")
+    return wait_for_reply(sid, timeout=timeout)
+
+
+if __name__ == "__main__":
+
+    if not sys.stdout.isatty():
+        try:
+            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+    test_chat = "test-opencode-client"
+    reply = ask(test_chat, "请用一句话回复:收到消息了吗?")
+    print(f"REPLY: {reply}")

+ 6 - 4
运营文案/xhs_feishu.py

@@ -17,9 +17,6 @@ import os
 import sys
 import time
 
-# 遵循项目规范:UTF-8 输出,避免 Windows GBK 乱码
-sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
-
 import requests
 
 FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
@@ -217,6 +214,11 @@ def send_daily_report(
 
 
 if __name__ == "__main__":
-    # 独立测试:本模块可作自检用
+
+    if not sys.stdout.isatty():
+        try:
+            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
     print("飞书推送工具模块加载成功")
     print(f"token URL: {FEISHU_TOKEN_URL}")

+ 339 - 165
运营文案/xhs_unreplied_comments.py

@@ -1,5 +1,15 @@
-# -*- coding: utf-8 -*-
-"""小红书评论检测脚本 - 检测有评论的笔记,推送带回复建议的通知"""
+"""小红书评论检测脚本 - 记录每篇笔记评论数,发现新增评论时读取具体评论并推送给回复建议
+
+运行逻辑:
+1. CDP 网络捕获 note-manager 的 posted API,获取每篇笔记的 id/xsec_token/comments_count/标题
+2. 与状态文件 xhs_comment_state.json 对比: 评论数比上次增加的笔记 → 打开详情页读取评论区
+3. 用 createTime > last_check_time 识别新增评论(含楼中楼回复)
+4. 每条新评论用 opencode 生成针对性回复建议
+5. 推送飞书(逐条评论+建议); 无新增评论则不推送
+6. 更新状态文件。首次运行只建立基线, 不推送
+"""
+import argparse
+import datetime
 import io
 import json
 import os
@@ -18,6 +28,7 @@ if not sys.stdout.isatty():
 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
 OPERATION_DIR = SCRIPT_DIR
 CONFIG_PATH = os.path.join(OPERATION_DIR, "xhs_daily_config.json")
+STATE_PATH = os.path.join(OPERATION_DIR, "xhs_comment_state.json")
 
 XHS_SKILLS_PATH = r"C:\code\XiaohongshuSkills\scripts"
 sys.path.insert(0, XHS_SKILLS_PATH)
@@ -25,6 +36,18 @@ sys.path.insert(0, OPERATION_DIR)
 
 from cdp_publish import XiaohongshuPublisher
 from xhs_feishu import _get_token, FEISHU_MSG_URL
+import opencode_client
+
+POSTED_API_PATH = "/api/galaxy/v2/creator/note/user/posted"
+NOTE_MANAGER_URL = "https://creator.xiaohongshu.com/new/note-manager"
+
+FALLBACK_SUGGESTIONS = [
+    "谢谢你这么用心地评论,看到你的留言真的很暖心~",
+    "哈哈哈你这个角度很特别,欢迎常来聊~",
+    "你的想法很有意思,我记下了,后面单独展开说说",
+    "谢谢支持!你提的这个点我改天专门写一篇",
+    "被你说中了,看来咱们想到一块儿去了 😄",
+]
 
 
 def _load_config():
@@ -32,140 +55,246 @@ def _load_config():
         return json.load(f)
 
 
-def extract_comment_counts(publisher, limit=10):
-    """从笔记管理页提取每篇笔记的评论数。
+def _load_state():
+    if not os.path.exists(STATE_PATH):
+        return {"notes": {}}
+    try:
+        with open(STATE_PATH, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception:
+        return {"notes": {}}
 
-    页面数字顺序:阅读 / 评论 / 点赞 / 收藏 / 分享
-    """
-    publisher._navigate("https://creator.xiaohongshu.com/new/note-manager")
-    time.sleep(6)
-
-    text = publisher._evaluate("document.body.innerText") or ""
-    lines = [ln.strip() for ln in text.split("\n")]
-
-    notes = []
-    i = 0
-    while i < len(lines) and len(notes) < limit:
-        ln = lines[i]
-        if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", ln) and i + 5 < len(lines):
-            title = lines[i - 1].strip() 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],
-                })
-            i = j if j > i + 1 else i + 1
-        else:
-            i += 1
-    return notes
 
+def _save_state(state):
+    tmp = STATE_PATH + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump(state, f, ensure_ascii=False, indent=1)
+    os.replace(tmp, STATE_PATH)
+
+
+def _now_ms() -> int:
+    return int(time.time() * 1000)
 
-# ─── 基于笔记标题生成针对性回复建议 ──────────────────────────────────────
-
-REPLY_TEMPLATES = {
-    # 教育/育儿类
-    "孩子": [
-        "你家孩子也这样吗?来评论区聊聊你的经历 😊",
-        "有没有同款娃?说说你家娃的情况~",
-        "这个方法你家试过吗?效果怎么样?",
-    ],
-    "家长": [
-        "作为家长,你们是怎么处理类似情况的?",
-        "大家在育儿路上遇到过什么难题?",
-        "评论区分享一下你们的经验吧 💬",
-    ],
-    "学习": [
-        "你们家孩子学习时有什么好习惯?",
-        "有没有其他方法可以试试?",
-        "大家在学习方法上有什么心得?",
-    ],
-    "小学": [
-        "小学生家长你们有什么建议?",
-        "有没有类似经历?分享一下~",
-        "大家怎么看待小学阶段的教育?",
-    ],
-    # 家庭/关系类
-    "家": [
-        "你家是怎么处理类似情况的?",
-        "评论区聊聊你们的家庭故事 🏠",
-        "大家有什么好的建议?",
-    ],
-    # 健康/生活类
-    "温度": [
-        "你们家室温一般控制在多少度?",
-        "有没有其他方法保持室内舒适?",
-        "评论区交流一下经验 🌡️",
-    ],
-    "睡": [
-        "你家宝宝睡觉怎么样?",
-        "有没有好的睡眠建议?",
-        "大家是怎么解决睡眠问题的?",
-    ],
-    # 消费/决策类
-    "决定": [
-        "你们做过什么让自己不后悔的决定?",
-        "评论区聊聊你的重要决定 💭",
-        "有没有类似经历?分享一下",
-    ],
-    "教育": [
-        "你们的教育理念是什么?",
-        "评论区交流一下育儿心得 📚",
-        "大家觉得最好的教育方式是什么?",
-    ],
-    "博主": [
-        "你们有没有被割韭菜的经历?",
-        "评论区说说你们的经验 💡",
-        "大家是怎么避坑的?",
-    ],
-    # 通用
-    "话题": [
-        "你们怎么看这个问题?",
-        "评论区说说你的想法 💬",
-        "大家有什么不同的看法?",
-    ],
-}
-
-GENERAL_SUGGESTIONS = [
-    "你觉得呢?来评论区聊聊~",
-    "大家有什么想说的?欢迎分享 🙌",
-    "欢迎在评论区分享你的经历",
-    "有没有同款经历?评论区见 👋",
-    "说说你的看法吧~",
-]
 
+def _fmt_ms(ms: int) -> str:
+    try:
+        return datetime.datetime.fromtimestamp(ms / 1000).strftime("%m-%d %H:%M")
+    except Exception:
+        return ""
 
-def generate_reply_suggestions(title, comment_count):
-    """根据笔记标题生成针对性的回复建议。"""
-    suggestions = []
-    title_lower = title.lower()
 
-    for keywords, hints in REPLY_TEMPLATES.items():
-        if keywords in title:
-            # 根据评论数选择建议数量(最多3条)
-            num = min(comment_count, 3)
-            suggestions.extend(hints[:num])
+def _parse_posted_body(body_text: str) -> list:
+    """解析 posted API 响应, 返回笔记列表。"""
+    try:
+        payload = json.loads(body_text)
+    except json.JSONDecodeError:
+        return []
+    notes = ((payload.get("data") or {}).get("notes")) or []
+    result = []
+    for n in notes:
+        if not isinstance(n, dict):
+            continue
+        note_id = str(n.get("id") or "").strip()
+        if not note_id:
+            continue
+        result.append({
+            "id": note_id,
+            "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),
+            "xsec_token": str(n.get("xsec_token") or "").strip(),
+            "publish_time": str(n.get("time") or "").strip(),
+        })
+    return result
+
+
+def capture_posted_notes(publisher, max_wait: float = 30.0) -> list:
+    """通过 CDP 网络捕获 note-manager 的 posted API, 滚动加载全部笔记。
+
+    注意: 滚动必须用裸 ws.send 发 Runtime.evaluate, 不能走 publisher._evaluate
+    (其内部 _send 会 recv 吞掉网络事件, 导致漏抓后续页面的 API 响应)。
+    """
+    publisher._send("Page.enable")
+    publisher._send("Network.enable", {"maxPostDataSize": 65536})
+    publisher._navigate(NOTE_MANAGER_URL)
+    time.sleep(3.0)
+
+    request_url_by_id = {}
+    target_request_ids = set()
+    start = time.time()
+    last_scroll_at = 0.0
+    last_new_at = time.time()
+    _scroll_cmd_id = 9000
+
+    while time.time() - start < max_wait:
+        # 捕获到请求后空闲 5s → 提前结束
+        if target_request_ids and time.time() - last_new_at > 5.0:
             break
 
-    # 如果没有匹配到模板,使用通用建议
-    if not suggestions:
-        suggestions = GENERAL_SUGGESTIONS[:min(comment_count, 2)]
+        timeout = min(0.8, max(0.1, max_wait - (time.time() - start)))
+        try:
+            raw = publisher.ws.recv(timeout=timeout)
+        except TimeoutError:
+            # 空闲期滚动触发下一页加载(裸 ws.send, 不 recv)
+            if time.time() - last_scroll_at > 2.5:
+                _scroll_cmd_id += 1
+                publisher.ws.send(json.dumps({
+                    "id": _scroll_cmd_id,
+                    "method": "Runtime.evaluate",
+                    "params": {"expression": "window.scrollTo(0, document.body.scrollHeight)"},
+                }))
+                last_scroll_at = time.time()
+            continue
 
-    return suggestions[:3]  # 最多3条
+        try:
+            message = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        method = message.get("method")
+        params = message.get("params", {})
+
+        if method == "Network.requestWillBeSent":
+            rid = params.get("requestId")
+            req = params.get("request", {})
+            if isinstance(rid, str):
+                request_url_by_id[rid] = req.get("url", "")
+        elif method == "Network.responseReceived":
+            rid = params.get("requestId")
+            if not isinstance(rid, str):
+                continue
+            url = request_url_by_id.get(rid, "")
+            if POSTED_API_PATH not in url:
+                continue
+            if params.get("response", {}).get("status") != 200:
+                continue
+            if rid not in target_request_ids:
+                target_request_ids.add(rid)
+                last_new_at = time.time()
+
+    # 空闲太久且已无新请求 → 停止
+    if not target_request_ids:
+        raise RuntimeError("未捕获到笔记列表 API 响应")
+
+    notes_by_id = {}
+    for rid in target_request_ids:
+        body_result = publisher._send("Network.getResponseBody", {"requestId": rid})
+        body_text = body_result.get("body", "")
+        if body_result.get("base64Encoded"):
+            import base64
+            body_text = base64.b64decode(body_text).decode("utf-8", errors="replace")
+        for n in _parse_posted_body(body_text):
+            notes_by_id[n["id"]] = n
+
+    notes = list(notes_by_id.values())
+    print(f"[capture] pages={len(target_request_ids)} notes={len(notes)}")
+    return notes
 
 
-def check_comments():
-    """检查评论并推送到飞书群"""
+def _norm_comment(c: dict, is_sub: bool) -> dict:
+    """规范化一条评论(父评论或楼中楼)。"""
+    content = str(c.get("content") or "").strip()
+    pictures = c.get("pictures") or []
+    if not content and pictures:
+        content = "[图片]"
+    user = c.get("userInfo") or {}
+    create_ms = int(c.get("createTime") or 0)
+    return {
+        "id": str(c.get("id") or "").strip(),
+        "content": content,
+        "nickname": str(user.get("nickname") or "").strip() or "匿名用户",
+        "create_ms": create_ms,
+        "is_sub": is_sub,
+    }
+
+
+def fetch_note_comments(publisher, note_id: str, xsec_token: str) -> list:
+    """打开笔记详情页读取评论(父评论+楼中楼)。"""
+    if not xsec_token:
+        return []
+    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,
+    )
+    detail = res.get("detail") or {}
+    comments_raw = detail.get("comments") or {}
+    parents = comments_raw.get("list") or []
+
+    out = []
+    for p in parents:
+        if not isinstance(p, dict):
+            continue
+        out.append(_norm_comment(p, is_sub=False))
+        for s in (p.get("subComments") or []):
+            if isinstance(s, dict):
+                out.append(_norm_comment(s, is_sub=True))
+    return out
+
+
+def generate_suggestions(title: str, new_comments: list) -> list:
+    """用 opencode 批量生成每条新评论的回复建议; 失败则回退模板。"""
+    lines = []
+    for i, c in enumerate(new_comments, 1):
+        lines.append(f"{i}. 用户「{c.get('nickname')}」:{c.get('content') or '(图片)'}")
+
+    prompt = (
+        "你是小红书博主「72年冻龄妈妈的家的算法」(家庭教育博主) 的运营助手。\n"
+        f"笔记《{title}》收到以下新评论({len(lines)}条),请为每一条写一条中文回复建议。\n"
+        "要求: 每条 15-40 字, 口语化、真诚、像真人博主, 避免套话和模板感, 可适当互动引导。\n\n"
+        "评论列表:\n" + "\n".join(lines) +
+        "\n\n请严格按以下格式输出, 每条一行:\n1. <回复内容>\n2. <回复内容>\n..."
+    )
+    try:
+        reply = opencode_client.ask("xhs-comment-suggest", prompt, timeout=120)
+    except Exception as e:
+        print(f"[suggest] opencode 失败, 使用模板回退: {e}")
+        return [FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)] for i in range(len(new_comments))]
+
+    parsed = []
+    for ln in reply.splitlines():
+        ln = ln.strip()
+        m = re.match(r"^(\d+)\s*[.、)::]\s*(.+)$", ln)
+        if m and m.group(2).strip():
+            parsed.append(m.group(2).strip())
+
+    if not parsed:
+        # 严格格式未命中, 按非空行盲取
+        parsed = [ln.strip() for ln in reply.splitlines() if ln.strip()]
+
+    if len(parsed) < len(new_comments):
+        for i in range(len(parsed), len(new_comments)):
+            parsed.append(FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)])
+    return parsed[:len(new_comments)]
+
+
+def push_feishu(cfg, text: str):
+    token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
+    url = f"{FEISHU_MSG_URL}?receive_id_type=chat_id"
+    r = requests.post(
+        url,
+        headers={"Authorization": f"Bearer {token}"},
+        json={
+            "receive_id": cfg["feishu_chat_id"],
+            "msg_type": "text",
+            "content": json.dumps({"text": text}, ensure_ascii=False),
+        },
+        timeout=20,
+    )
+    d = r.json()
+    if d.get("code") != 0:
+        raise RuntimeError(f"推送失败: {d.get('msg')}")
+    return True
+
+
+def check_comments(dry_run: bool = False):
+    """检查新增评论并推送飞书"""
     cfg = _load_config()
+    state = _load_state()
+    notes_state = state.setdefault("notes", {})
+
     publisher = XiaohongshuPublisher()
     publisher.connect(reuse_existing_tab=True)
 
@@ -173,54 +302,96 @@ def check_comments():
         if not publisher.check_login():
             raise RuntimeError("未登录小红书")
 
-        notes = extract_comment_counts(publisher, limit=10)
-        noted_with_comments = [n for n in notes if n["comment_count"] > 0]
-
-        if not noted_with_comments:
-            print("没有发现有评论的笔记")
+        notes = capture_posted_notes(publisher)
+        now = _now_ms()
+        first_run = not notes_state
+        changed = []
+
+        for n in notes:
+            nid = n["id"]
+            prev = notes_state.get(nid)
+
+            if prev is None:
+                # 新笔记: 建立基线
+                notes_state[nid] = {
+                    "title": n["title"],
+                    "comment_count": n["comments_count"],
+                    "last_check_time": now,
+                }
+                continue
+
+            prev_count = int(prev.get("comment_count") or 0)
+            if n["comments_count"] <= prev_count:
+                continue
+
+            # 评论数增加 → 打开评论区
+            try:
+                comments = fetch_note_comments(publisher, nid, n.get("xsec_token") or "")
+            except Exception as e:
+                print(f"[fetch] 笔记 {nid} 评论区读取失败, 跳过: {e}")
+                continue
+
+            last_check_ms = int(prev.get("last_check_time") or 0)
+            new = [c for c in comments if c["create_ms"] > last_check_ms and c["id"]]
+            if not new:
+                # 评论被删除/替换, 只更新计数
+                notes_state[nid]["comment_count"] = n["comments_count"]
+                continue
+
+            suggestions = generate_suggestions(n["title"], new)
+            for i, c in enumerate(new):
+                c["suggestion"] = suggestions[i] if i < len(suggestions) else FALLBACK_SUGGESTIONS[i % len(FALLBACK_SUGGESTIONS)]
+
+            changed.append({
+                "note": n,
+                "new_comments": new,
+            })
+            # 更新基线: 计数 + 已读时间(取已读评论的最大时间, 防止漏报)
+            max_create = max(c["create_ms"] for c in new)
+            notes_state[nid]["comment_count"] = n["comments_count"]
+            notes_state[nid]["last_check_time"] = max(now, max_create)
+            notes_state[nid]["title"] = n["title"]
+
+        _save_state(state)
+
+        if first_run:
+            print("[INIT] 首次运行: 已建立全部笔记基线, 不推送")
             return
 
-        date = time.strftime("%Y-%m-%d")
+        if not changed:
+            print("[SKIP] 无新增评论, 不推送")
+            return
 
-        # 构建消息
-        lines = []
-        for n in noted_with_comments:
-            suggestions = generate_reply_suggestions(n["title"], n["comment_count"])
-            sug_text = "\n".join(f"  · {s}" for s in suggestions)
-            lines.append(
-                f"  · 《{n['title']}》({n['publish_date']})\n"
-                f"    评论:{n['comment_count']} 条 | 阅读:{n['read_count']}\n"
-                f"    💡 建议回复:\n{sug_text}"
-            )
+        date = time.strftime("%Y-%m-%d %H:%M")
+        sections = []
+        for ch in changed:
+            note = ch["note"]
+            new_c = ch["new_comments"]
+            lines = [f"《{note['title']}》 评论 +{len(new_c)} (共 {note['comments_count']} 条)"]
+            if note.get("publish_time"):
+                lines[0] += f" | 发布 {note['publish_time']}"
+            for c in new_c:
+                kind = "回复" if c.get("is_sub") else "评论"
+                lines.append(
+                    f"· [{kind}] @{c.get('nickname')} ({_fmt_ms(c.get('create_ms'))})\n"
+                    f"  {c.get('content') or '(图片)'}\n"
+                    f"  💡 建议回复: {c.get('suggestion')}"
+                )
+            sections.append("\n".join(lines))
 
         msg = (
-            f"📬 小红书评论提醒({date})\n\n"
-            + "\n\n".join(lines)
-            + "\n\n💬 提示:以上为有评论的笔记,请及时回复以增加互动和粉丝粘性"
+            f"📬 小红书新增评论提醒({date})\n\n"
+            + "\n\n".join(sections)
+            + "\n\n💬 提示:以上为新增评论,请及时回复以增加互动和粉丝粘性"
         )
         print(msg)
 
-        # 推送
-        try:
-            token = _get_token(cfg["feishu_app_id"], cfg["feishu_app_secret"])
-            url = f"{FEISHU_MSG_URL}?receive_id_type=chat_id"
-            r = requests.post(
-                url,
-                headers={"Authorization": f"Bearer {token}"},
-                json={
-                    "receive_id": cfg["feishu_chat_id"],
-                    "msg_type": "text",
-                    "content": json.dumps({"text": msg}, ensure_ascii=False),
-                },
-                timeout=20,
-            )
-            d = r.json()
-            if d.get("code") == 0:
-                print(f"[OK] 评论通知已推送")
-            else:
-                print(f"[WARN] 推送失败: {d.get('msg')}")
-        except Exception as e:
-            print(f"推送失败: {e}")
+        if dry_run:
+            print("[DRY-RUN] 未实际推送")
+            return
+
+        push_feishu(cfg, msg)
+        print("[OK] 评论通知已推送")
 
     except Exception as e:
         print(f"检查评论失败: {e}")
@@ -234,4 +405,7 @@ def check_comments():
 
 
 if __name__ == "__main__":
-    check_comments()
+    parser = argparse.ArgumentParser(description="小红书新增评论检测")
+    parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
+    args = parser.parse_args()
+    check_comments(dry_run=args.dry_run)