feat: 多项 CDP tab 管理优化与技能边界加固
- cli.py: 引入 _SESSION_TAB_FILE 机制,_connect() 优先复用上次命令 留下的 tab,彻底解决多命令流程中 tab 不断堆积的问题 - cli.py: cmd_check_login 不再关闭页面,tab 保持存活供下次复用 - cli.py: delete-cookies 退出登录时清除会话 tab 记录 - cdp.py: 新增 get_or_create_page()(复用空白 tab)、 _setup_page() 辅助方法、get_page_by_target_id()、get_existing_page() - title_utils.py: 新增 truncate_title() 工具函数 - 所有 SKILL.md: 添加技能边界约束,防止 AI 记忆中的其他项目 (如 xiaohongshu-mcp)干扰执行 - xhs-publish/SKILL.md: 新增图片 URL 直传规则(禁止手动下载)、 save-draft 取消流程、标题长度计算说明与重新生成要求
Showing
9 changed files
with
294 additions
and
42 deletions
| @@ -9,6 +9,17 @@ description: | | @@ -9,6 +9,17 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书自动化助手"。根据用户意图路由到对应的子技能完成任务。 | 10 | 你是"小红书自动化助手"。根据用户意图路由到对应的子技能完成任务。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有小红书操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具、Go 工具或其他小红书自动化方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:任务完成后直接告知结果,等待用户下一步指令。 | ||
| 20 | + | ||
| 21 | +--- | ||
| 22 | + | ||
| 12 | ## 输入判断 | 23 | ## 输入判断 |
| 13 | 24 | ||
| 14 | 按优先级判断用户意图,路由到对应子技能: | 25 | 按优先级判断用户意图,路由到对应子技能: |
| @@ -18,6 +18,9 @@ import tempfile | @@ -18,6 +18,9 @@ import tempfile | ||
| 18 | # 记录登录用 tab 的 target_id,确保 verify-code / wait-login 连回精确的那个 tab | 18 | # 记录登录用 tab 的 target_id,确保 verify-code / wait-login 连回精确的那个 tab |
| 19 | _LOGIN_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "login_tab_id.txt") | 19 | _LOGIN_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "login_tab_id.txt") |
| 20 | 20 | ||
| 21 | +# 记录上次命令使用的 tab,供下次命令复用,避免重复开新 tab | ||
| 22 | +_SESSION_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "session_tab_id.txt") | ||
| 23 | + | ||
| 21 | 24 | ||
| 22 | def _save_login_tab(target_id: str) -> None: | 25 | def _save_login_tab(target_id: str) -> None: |
| 23 | os.makedirs(os.path.dirname(_LOGIN_TAB_FILE), exist_ok=True) | 26 | os.makedirs(os.path.dirname(_LOGIN_TAB_FILE), exist_ok=True) |
| @@ -36,6 +39,24 @@ def _clear_login_tab() -> None: | @@ -36,6 +39,24 @@ def _clear_login_tab() -> None: | ||
| 36 | with contextlib.suppress(FileNotFoundError): | 39 | with contextlib.suppress(FileNotFoundError): |
| 37 | os.remove(_LOGIN_TAB_FILE) | 40 | os.remove(_LOGIN_TAB_FILE) |
| 38 | 41 | ||
| 42 | + | ||
| 43 | +def _save_session_tab(target_id: str) -> None: | ||
| 44 | + os.makedirs(os.path.dirname(_SESSION_TAB_FILE), exist_ok=True) | ||
| 45 | + with open(_SESSION_TAB_FILE, "w") as f: | ||
| 46 | + f.write(target_id) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def _load_session_tab() -> str | None: | ||
| 50 | + with contextlib.suppress(FileNotFoundError): | ||
| 51 | + data = open(_SESSION_TAB_FILE).read().strip() | ||
| 52 | + return data or None | ||
| 53 | + return None | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def _clear_session_tab() -> None: | ||
| 57 | + with contextlib.suppress(FileNotFoundError): | ||
| 58 | + os.remove(_SESSION_TAB_FILE) | ||
| 59 | + | ||
| 39 | # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8 | 60 | # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8 |
| 40 | if sys.stdout and hasattr(sys.stdout, "reconfigure"): | 61 | if sys.stdout and hasattr(sys.stdout, "reconfigure"): |
| 41 | sys.stdout.reconfigure(encoding="utf-8") | 62 | sys.stdout.reconfigure(encoding="utf-8") |
| @@ -56,7 +77,11 @@ def _output(data: dict, exit_code: int = 0) -> None: | @@ -56,7 +77,11 @@ def _output(data: dict, exit_code: int = 0) -> None: | ||
| 56 | 77 | ||
| 57 | 78 | ||
| 58 | def _connect(args: argparse.Namespace): | 79 | def _connect(args: argparse.Namespace): |
| 59 | - """连接到 Chrome 并返回 (browser, page)。""" | 80 | + """连接到 Chrome 并返回 (browser, page)。 |
| 81 | + | ||
| 82 | + 优先复用上次命令留下的 tab(通过 _SESSION_TAB_FILE 记录), | ||
| 83 | + 避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。 | ||
| 84 | + """ | ||
| 60 | from chrome_launcher import ensure_chrome, has_display | 85 | from chrome_launcher import ensure_chrome, has_display |
| 61 | from xhs.cdp import Browser | 86 | from xhs.cdp import Browser |
| 62 | 87 | ||
| @@ -68,7 +93,19 @@ def _connect(args: argparse.Namespace): | @@ -68,7 +93,19 @@ def _connect(args: argparse.Namespace): | ||
| 68 | 93 | ||
| 69 | browser = Browser(host=args.host, port=args.port) | 94 | browser = Browser(host=args.host, port=args.port) |
| 70 | browser.connect() | 95 | browser.connect() |
| 71 | - page = browser.new_page() | 96 | + |
| 97 | + # 优先复用上次命令留下的 tab | ||
| 98 | + saved_id = _load_session_tab() | ||
| 99 | + if saved_id: | ||
| 100 | + page = browser.get_page_by_target_id(saved_id) | ||
| 101 | + if page: | ||
| 102 | + logger.debug("复用会话 tab: %s", saved_id) | ||
| 103 | + _save_session_tab(page.target_id) | ||
| 104 | + return browser, page | ||
| 105 | + logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id) | ||
| 106 | + | ||
| 107 | + page = browser.get_or_create_page() | ||
| 108 | + _save_session_tab(page.target_id) | ||
| 72 | return browser, page | 109 | return browser, page |
| 73 | 110 | ||
| 74 | 111 | ||
| @@ -184,7 +221,7 @@ def cmd_check_login(args: argparse.Namespace) -> None: | @@ -184,7 +221,7 @@ def cmd_check_login(args: argparse.Namespace) -> None: | ||
| 184 | ), | 221 | ), |
| 185 | }, exit_code=1) | 222 | }, exit_code=1) |
| 186 | finally: | 223 | finally: |
| 187 | - browser.close_page(page) | 224 | + # 不关闭 tab,保留页面供下次命令复用(_SESSION_TAB_FILE) |
| 188 | browser.close() | 225 | browser.close() |
| 189 | 226 | ||
| 190 | 227 | ||
| @@ -388,6 +425,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None: | @@ -388,6 +425,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None: | ||
| 388 | path = get_cookies_file_path(args.account) | 425 | path = get_cookies_file_path(args.account) |
| 389 | delete_cookies(path) | 426 | delete_cookies(path) |
| 390 | 427 | ||
| 428 | + _clear_session_tab() # 退出登录后清除会话 tab 记录 | ||
| 391 | msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件" | 429 | msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件" |
| 392 | _output({"success": True, "message": msg, "cookies_path": path}) | 430 | _output({"success": True, "message": msg, "cookies_path": path}) |
| 393 | 431 |
| @@ -5,6 +5,23 @@ from __future__ import annotations | @@ -5,6 +5,23 @@ from __future__ import annotations | ||
| 5 | MAX_TITLE_LENGTH = 20 | 5 | MAX_TITLE_LENGTH = 20 |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | +def truncate_title(s: str, max_length: int = MAX_TITLE_LENGTH) -> str: | ||
| 9 | + """将标题裁剪到 max_length 以内(逐字符从末尾去除)。 | ||
| 10 | + | ||
| 11 | + Args: | ||
| 12 | + s: 原始标题。 | ||
| 13 | + max_length: 最大允许长度(默认 20)。 | ||
| 14 | + | ||
| 15 | + Returns: | ||
| 16 | + 满足长度要求的标题字符串。 | ||
| 17 | + """ | ||
| 18 | + if calc_title_length(s) <= max_length: | ||
| 19 | + return s | ||
| 20 | + while s and calc_title_length(s) > max_length: | ||
| 21 | + s = s[:-1] | ||
| 22 | + return s | ||
| 23 | + | ||
| 24 | + | ||
| 8 | def calc_title_length(s: str) -> int: | 25 | def calc_title_length(s: str) -> int: |
| 9 | """计算小红书标题长度。 | 26 | """计算小红书标题长度。 |
| 10 | 27 |
| @@ -534,35 +534,12 @@ class Browser: | @@ -534,35 +534,12 @@ class Browser: | ||
| 534 | logger.info("连接到 Chrome: %s", ws_url) | 534 | logger.info("连接到 Chrome: %s", ws_url) |
| 535 | self._cdp = CDPClient(ws_url) | 535 | self._cdp = CDPClient(ws_url) |
| 536 | 536 | ||
| 537 | - def new_page(self, url: str = "about:blank") -> Page: | ||
| 538 | - """创建新页面。""" | ||
| 539 | - if not self._cdp: | ||
| 540 | - self.connect() | ||
| 541 | - assert self._cdp is not None | ||
| 542 | - | ||
| 543 | - # 创建 target | ||
| 544 | - result = self._cdp.send("Target.createTarget", {"url": url}) | ||
| 545 | - target_id = result["targetId"] | ||
| 546 | - | ||
| 547 | - # 附加到 target | ||
| 548 | - result = self._cdp.send( | ||
| 549 | - "Target.attachToTarget", | ||
| 550 | - {"targetId": target_id, "flatten": True}, | ||
| 551 | - ) | ||
| 552 | - session_id = result["sessionId"] | ||
| 553 | - | ||
| 554 | - page = Page(self._cdp, target_id, session_id) | 537 | + def _setup_page(self, page: Page) -> Page: |
| 538 | + """为 Page 对象注入 stealth、UA、viewport,并启用必要的 CDP domain。""" | ||
| 539 | + import contextlib | ||
| 555 | 540 | ||
| 556 | - # 注入反检测(必须在 enable domains 之前) | ||
| 557 | page.inject_stealth() | 541 | page.inject_stealth() |
| 558 | - | ||
| 559 | - # UA 覆盖 | ||
| 560 | - page._send_session( | ||
| 561 | - "Emulation.setUserAgentOverride", | ||
| 562 | - {"userAgent": REALISTIC_UA}, | ||
| 563 | - ) | ||
| 564 | - | ||
| 565 | - # 随机 viewport(模拟真实屏幕尺寸) | 542 | + page._send_session("Emulation.setUserAgentOverride", {"userAgent": REALISTIC_UA}) |
| 566 | page._send_session( | 543 | page._send_session( |
| 567 | "Emulation.setDeviceMetricsOverride", | 544 | "Emulation.setDeviceMetricsOverride", |
| 568 | { | 545 | { |
| @@ -572,24 +549,67 @@ class Browser: | @@ -572,24 +549,67 @@ class Browser: | ||
| 572 | "mobile": False, | 549 | "mobile": False, |
| 573 | }, | 550 | }, |
| 574 | ) | 551 | ) |
| 575 | - | ||
| 576 | - # 拒绝权限弹窗(位置、通知等) | ||
| 577 | - import contextlib | ||
| 578 | - | ||
| 579 | for perm in ("geolocation", "notifications", "midi", "camera", "microphone"): | 552 | for perm in ("geolocation", "notifications", "midi", "camera", "microphone"): |
| 580 | with contextlib.suppress(CDPError): | 553 | with contextlib.suppress(CDPError): |
| 554 | + assert self._cdp is not None | ||
| 581 | self._cdp.send( | 555 | self._cdp.send( |
| 582 | "Browser.setPermission", | 556 | "Browser.setPermission", |
| 583 | {"permission": {"name": perm}, "setting": "denied"}, | 557 | {"permission": {"name": perm}, "setting": "denied"}, |
| 584 | ) | 558 | ) |
| 585 | - | ||
| 586 | - # 启用必要的 domain | ||
| 587 | page._send_session("Page.enable") | 559 | page._send_session("Page.enable") |
| 588 | page._send_session("DOM.enable") | 560 | page._send_session("DOM.enable") |
| 589 | page._send_session("Runtime.enable") | 561 | page._send_session("Runtime.enable") |
| 590 | - | ||
| 591 | return page | 562 | return page |
| 592 | 563 | ||
| 564 | + def new_page(self, url: str = "about:blank") -> Page: | ||
| 565 | + """创建新页面(强制开新 tab)。""" | ||
| 566 | + if not self._cdp: | ||
| 567 | + self.connect() | ||
| 568 | + assert self._cdp is not None | ||
| 569 | + | ||
| 570 | + result = self._cdp.send("Target.createTarget", {"url": url}) | ||
| 571 | + target_id = result["targetId"] | ||
| 572 | + result = self._cdp.send( | ||
| 573 | + "Target.attachToTarget", | ||
| 574 | + {"targetId": target_id, "flatten": True}, | ||
| 575 | + ) | ||
| 576 | + session_id = result["sessionId"] | ||
| 577 | + return self._setup_page(Page(self._cdp, target_id, session_id)) | ||
| 578 | + | ||
| 579 | + def get_or_create_page(self) -> Page: | ||
| 580 | + """复用现有空白 tab,找不到时才新建。 | ||
| 581 | + | ||
| 582 | + 避免每次命令都创建新 tab 导致 Chrome 中 tab 无限堆积。 | ||
| 583 | + 空白 tab 判定:url 为 about:blank 或 chrome://newtab/。 | ||
| 584 | + """ | ||
| 585 | + if not self._cdp: | ||
| 586 | + self.connect() | ||
| 587 | + assert self._cdp is not None | ||
| 588 | + | ||
| 589 | + import contextlib | ||
| 590 | + | ||
| 591 | + resp = requests.get(f"{self.base_url}/json", timeout=5) | ||
| 592 | + targets = resp.json() | ||
| 593 | + | ||
| 594 | + for target in targets: | ||
| 595 | + if target.get("type") == "page" and target.get("url") in ( | ||
| 596 | + "about:blank", | ||
| 597 | + "chrome://newtab/", | ||
| 598 | + ): | ||
| 599 | + target_id = target["id"] | ||
| 600 | + with contextlib.suppress(Exception): | ||
| 601 | + result = self._cdp.send( | ||
| 602 | + "Target.attachToTarget", | ||
| 603 | + {"targetId": target_id, "flatten": True}, | ||
| 604 | + ) | ||
| 605 | + session_id = result.get("sessionId") | ||
| 606 | + if session_id: | ||
| 607 | + logger.debug("复用空白 tab: %s", target_id) | ||
| 608 | + return self._setup_page(Page(self._cdp, target_id, session_id)) | ||
| 609 | + | ||
| 610 | + # 没有空白 tab,新建一个 | ||
| 611 | + return self.new_page() | ||
| 612 | + | ||
| 593 | def get_page_by_target_id(self, target_id: str) -> Page | None: | 613 | def get_page_by_target_id(self, target_id: str) -> Page | None: |
| 594 | """通过 target_id 精确连接到指定 tab。""" | 614 | """通过 target_id 精确连接到指定 tab。""" |
| 595 | if not self._cdp: | 615 | if not self._cdp: |
| @@ -9,6 +9,28 @@ description: | | @@ -9,6 +9,28 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书认证助手"。负责管理小红书登录状态和多账号切换。 | 10 | 你是"小红书认证助手"。负责管理小红书登录状态和多账号切换。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有认证操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书登录方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:登录流程结束后,直接告知结果,等待用户下一步指令,不主动触发其他功能。 | ||
| 20 | + | ||
| 21 | +**本技能允许使用的全部 CLI 子命令:** | ||
| 22 | + | ||
| 23 | +| 子命令 | 用途 | | ||
| 24 | +|--------|------| | ||
| 25 | +| `check-login` | 检查当前登录状态 | | ||
| 26 | +| `get-qrcode` | 获取二维码图片(非阻塞) | | ||
| 27 | +| `wait-login` | 等待扫码完成(阻塞) | | ||
| 28 | +| `send-code --phone` | 发送手机验证码 | | ||
| 29 | +| `verify-code --code` | 提交验证码完成登录 | | ||
| 30 | +| `delete-cookies` | 退出登录并清除 cookies | | ||
| 31 | + | ||
| 32 | +--- | ||
| 33 | + | ||
| 12 | ## 输入判断 | 34 | ## 输入判断 |
| 13 | 35 | ||
| 14 | 按优先级判断用户意图: | 36 | 按优先级判断用户意图: |
| @@ -104,5 +126,5 @@ python scripts/cli.py --account work delete-cookies # 指定账号 | @@ -104,5 +126,5 @@ python scripts/cli.py --account work delete-cookies # 指定账号 | ||
| 104 | - **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。 | 126 | - **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。 |
| 105 | - **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`。 | 127 | - **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`。 |
| 106 | - **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`。 | 128 | - **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`。 |
| 107 | -- **二维码超时**:重新执行 `login` 命令。 | 129 | +- **二维码超时**:重新执行 `get-qrcode` 获取新二维码,再运行 `wait-login`。 |
| 108 | - **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`。 | 130 | - **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`。 |
| @@ -9,6 +9,32 @@ description: | | @@ -9,6 +9,32 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书内容运营助手"。帮助用户完成需要多步骤组合的运营任务。 | 10 | 你是"小红书内容运营助手"。帮助用户完成需要多步骤组合的运营任务。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有运营操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书运营方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:每个工作流步骤完成后向用户报告进度,等待确认后继续。 | ||
| 20 | + | ||
| 21 | +**本技能允许使用的全部 CLI 子命令:** | ||
| 22 | + | ||
| 23 | +| 子命令 | 用途 | | ||
| 24 | +|--------|------| | ||
| 25 | +| `search-feeds` | 搜索笔记(支持筛选) | | ||
| 26 | +| `list-feeds` | 获取首页推荐 Feed | | ||
| 27 | +| `get-feed-detail` | 获取笔记详情和评论 | | ||
| 28 | +| `user-profile` | 获取用户主页信息 | | ||
| 29 | +| `post-comment` | 发表评论(需用户确认) | | ||
| 30 | +| `like-feed` | 点赞笔记 | | ||
| 31 | +| `favorite-feed` | 收藏笔记 | | ||
| 32 | +| `publish` | 图文发布(需用户确认) | | ||
| 33 | +| `fill-publish` | 填写图文表单(分步发布) | | ||
| 34 | +| `click-publish` | 点击发布按钮 | | ||
| 35 | + | ||
| 36 | +--- | ||
| 37 | + | ||
| 12 | ## 输入判断 | 38 | ## 输入判断 |
| 13 | 39 | ||
| 14 | 按优先级判断: | 40 | 按优先级判断: |
| @@ -9,6 +9,26 @@ description: | | @@ -9,6 +9,26 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书内容发现助手"。帮助用户搜索、浏览和分析小红书内容。 | 10 | 你是"小红书内容发现助手"。帮助用户搜索、浏览和分析小红书内容。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有搜索和浏览操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书搜索方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:搜索或浏览流程结束后,直接告知结果,等待用户下一步指令。 | ||
| 20 | + | ||
| 21 | +**本技能允许使用的全部 CLI 子命令:** | ||
| 22 | + | ||
| 23 | +| 子命令 | 用途 | | ||
| 24 | +|--------|------| | ||
| 25 | +| `list-feeds` | 获取首页推荐 Feed | | ||
| 26 | +| `search-feeds` | 关键词搜索笔记(支持筛选) | | ||
| 27 | +| `get-feed-detail` | 获取笔记完整内容和评论 | | ||
| 28 | +| `user-profile` | 获取用户主页信息 | | ||
| 29 | + | ||
| 30 | +--- | ||
| 31 | + | ||
| 12 | ## 输入判断 | 32 | ## 输入判断 |
| 13 | 33 | ||
| 14 | 按优先级判断: | 34 | 按优先级判断: |
| @@ -9,6 +9,26 @@ description: | | @@ -9,6 +9,26 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书互动助手"。帮助用户在小红书上进行社交互动。 | 10 | 你是"小红书互动助手"。帮助用户在小红书上进行社交互动。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有互动操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书互动方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:互动流程结束后,直接告知结果,等待用户下一步指令。 | ||
| 20 | + | ||
| 21 | +**本技能允许使用的全部 CLI 子命令:** | ||
| 22 | + | ||
| 23 | +| 子命令 | 用途 | | ||
| 24 | +|--------|------| | ||
| 25 | +| `post-comment` | 对笔记发表评论 | | ||
| 26 | +| `reply-comment` | 回复指定评论或用户 | | ||
| 27 | +| `like-feed` | 点赞 / 取消点赞 | | ||
| 28 | +| `favorite-feed` | 收藏 / 取消收藏 | | ||
| 29 | + | ||
| 30 | +--- | ||
| 31 | + | ||
| 12 | ## 输入判断 | 32 | ## 输入判断 |
| 13 | 33 | ||
| 14 | 按优先级判断: | 34 | 按优先级判断: |
| @@ -9,6 +9,31 @@ description: | | @@ -9,6 +9,31 @@ description: | | ||
| 9 | 9 | ||
| 10 | 你是"小红书发布助手"。目标是在用户确认后,调用脚本完成内容发布。 | 10 | 你是"小红书发布助手"。目标是在用户确认后,调用脚本完成内容发布。 |
| 11 | 11 | ||
| 12 | +## 🔒 技能边界(强制) | ||
| 13 | + | ||
| 14 | +**所有发布操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | ||
| 15 | + | ||
| 16 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>` 或 `python scripts/publish_pipeline.py`,不得使用其他任何实现方式。 | ||
| 17 | +- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书发布方案,执行时必须全部忽略,只使用本项目的脚本。 | ||
| 18 | +- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | ||
| 19 | +- **完成即止**:发布流程结束后,直接告知结果,等待用户下一步指令。 | ||
| 20 | + | ||
| 21 | +**本技能允许使用的全部 CLI 子命令:** | ||
| 22 | + | ||
| 23 | +| 子命令 | 用途 | | ||
| 24 | +|--------|------| | ||
| 25 | +| `fill-publish` | 填写图文表单(不发布) | | ||
| 26 | +| `fill-publish-video` | 填写视频表单(不发布) | | ||
| 27 | +| `publish` | 图文一步发布 | | ||
| 28 | +| `publish-video` | 视频一步发布 | | ||
| 29 | +| `click-publish` | 点击发布按钮 | | ||
| 30 | +| `long-article` | 填写长文内容并触发排版 | | ||
| 31 | +| `select-template` | 选择长文排版模板 | | ||
| 32 | +| `next-step` | 进入长文发布页并填写描述 | | ||
| 33 | +| `publish_pipeline.py` | 发布流水线(含图片下载) | | ||
| 34 | + | ||
| 35 | +--- | ||
| 36 | + | ||
| 12 | ## 输入判断 | 37 | ## 输入判断 |
| 13 | 38 | ||
| 14 | 按优先级判断: | 39 | 按优先级判断: |
| @@ -25,7 +50,7 @@ description: | | @@ -25,7 +50,7 @@ description: | | ||
| 25 | - **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。 | 50 | - **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。 |
| 26 | - 图文发布时,没有图片不得发布。 | 51 | - 图文发布时,没有图片不得发布。 |
| 27 | - 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。 | 52 | - 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。 |
| 28 | -- 标题长度不超过 20(UTF-16 编码计算,中文字符计 1,英文/数字/空格计 1)。 | 53 | +- 标题长度不超过 20(UTF-16 字节数向上取整除以 2:汉字/全角符号计 1,英文/数字/半角符号每 **2 个**计 1)。例:"hello"= 3,"你好hello" = 4,勿用"每个字符计 1"估算。 |
| 29 | - 如果使用文件路径,必须使用绝对路径,禁止相对路径。 | 54 | - 如果使用文件路径,必须使用绝对路径,禁止相对路径。 |
| 30 | - 需要先有运行中的 Chrome,且已登录。 | 55 | - 需要先有运行中的 Chrome,且已登录。 |
| 31 | 56 | ||
| @@ -42,10 +67,34 @@ description: | | @@ -42,10 +67,34 @@ description: | | ||
| 42 | 3. 适当总结内容,保持语言自然、适合小红书阅读习惯。 | 67 | 3. 适当总结内容,保持语言自然、适合小红书阅读习惯。 |
| 43 | 4. 如果提取不到图片,告知用户手动获取。 | 68 | 4. 如果提取不到图片,告知用户手动获取。 |
| 44 | 69 | ||
| 70 | +#### 图片提取规则(URL 模式下,必须遵守) | ||
| 71 | + | ||
| 72 | +网页常用懒加载技术,`img` 标签的 `src` 可能是占位图,真实图片在 `data-src`: | ||
| 73 | + | ||
| 74 | +- **优先取 `data-src`**:若 `img` 标签同时有 `src` 和 `data-src`,以 `data-src` 为准(这是真实图片)。 | ||
| 75 | +- **跳过占位图**:`src` 路径含 `/shims/`、`/placeholder`、`/theme/`、`/themes/`、`16x9.png`、`1x1.png` 等的图片为占位符,直接忽略。 | ||
| 76 | +- **只取内容图**:只选正文主体区域的截图/配图,跳过网站 logo、图标、视频封面缩略图。 | ||
| 77 | +- **格式验证**:图片 URL 应以 `.jpg`、`.jpeg`、`.png`、`.webp`、`.gif` 结尾,否则跳过。 | ||
| 78 | +- **不要重试猜测**:按上述规则提取图片后直接使用,如果图片确实为空,告知用户手动提供,不要反复尝试不同的图片 URL。 | ||
| 79 | + | ||
| 45 | ### Step A.2: 内容检查 | 80 | ### Step A.2: 内容检查 |
| 46 | 81 | ||
| 47 | #### 标题检查 | 82 | #### 标题检查 |
| 48 | -标题长度必须 ≤ 20(UTF-16 编码长度)。如果超长,自动生成符合长度的新标题。 | 83 | +标题长度必须 ≤ 20(UTF-16 字节数向上取整除以 2)。规则:汉字/全角符号计 1,英文/数字/半角符号每 2 个计 1(单个也算 1)。 |
| 84 | + | ||
| 85 | +**超长时的处理(禁止机械截断):** | ||
| 86 | +1. 计算当前标题长度,如果超过 20,**目标是生成一个恰好 20 单位的新标题**。 | ||
| 87 | +2. 根据原标题核心含义重新创作,不限于原有词汇,可以重新措辞。 | ||
| 88 | +3. 生成后重新计算长度:等于 20 最佳,不足 20 则尝试补充修饰词,仍超过 20 则继续调整。 | ||
| 89 | +4. 反复迭代直到长度恰好为 20,最多允许 ±1(即 19 或 20)。 | ||
| 90 | +5. 直接使用新标题,无需询问用户。 | ||
| 91 | + | ||
| 92 | +示例: | ||
| 93 | +- 原标题(21):`Windows 11 迎来 MIDI 2.0!音乐人的重大升级` | ||
| 94 | +- 目标(20):`Windows 11 迎来 MIDI 2.0,音乐制作新体验` | ||
| 95 | + - ASCII×18 → 18字节,全角×1+中文×7 → 16字节,合计40 → 20 ✓ | ||
| 96 | + | ||
| 97 | +**注意**:ASCII 字符(英文/数字/空格)每个只占 0.5 个单位,要达到 20 往往需要比预期更多的字符。生成后务必重新估算,不要凭感觉判断长度。 | ||
| 49 | 98 | ||
| 50 | #### 正文格式 | 99 | #### 正文格式 |
| 51 | - 段落之间使用双换行分隔。 | 100 | - 段落之间使用双换行分隔。 |
| @@ -62,6 +111,23 @@ description: | | @@ -62,6 +111,23 @@ description: | | ||
| 62 | 111 | ||
| 63 | ### Step A.5: 执行发布(推荐分步方式) | 112 | ### Step A.5: 执行发布(推荐分步方式) |
| 64 | 113 | ||
| 114 | +#### 图片路径说明(重要) | ||
| 115 | + | ||
| 116 | +`--images` 支持本地路径和 HTTP/HTTPS URL,**脚本会自动下载 URL 图片,无需手动 curl/wget/下载**。 | ||
| 117 | + | ||
| 118 | +```bash | ||
| 119 | +# URL 图片:直接传 URL,脚本自动下载 | ||
| 120 | +--images "https://example.com/pic1.jpg" "https://example.com/pic2.png" | ||
| 121 | + | ||
| 122 | +# 本地图片:传绝对路径 | ||
| 123 | +--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" | ||
| 124 | + | ||
| 125 | +# 混合使用也支持 | ||
| 126 | +--images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg" | ||
| 127 | +``` | ||
| 128 | + | ||
| 129 | +**禁止手动下载图片**:不要用 curl、wget 或其他工具先下载图片再传路径,直接传 URL 即可,否则会因路径猜测错误而失败。 | ||
| 130 | + | ||
| 65 | #### 分步发布(推荐) | 131 | #### 分步发布(推荐) |
| 66 | 132 | ||
| 67 | 先填写表单,让用户在浏览器中确认预览后再发布: | 133 | 先填写表单,让用户在浏览器中确认预览后再发布: |
| @@ -78,10 +144,16 @@ python scripts/cli.py fill-publish \ | @@ -78,10 +144,16 @@ python scripts/cli.py fill-publish \ | ||
| 78 | 144 | ||
| 79 | # 步骤 2: 通过 AskUserQuestion 让用户确认浏览器中的预览 | 145 | # 步骤 2: 通过 AskUserQuestion 让用户确认浏览器中的预览 |
| 80 | 146 | ||
| 81 | -# 步骤 3: 点击发布 | 147 | +# 步骤 3a: 用户确认发布 |
| 82 | python scripts/cli.py click-publish | 148 | python scripts/cli.py click-publish |
| 149 | + | ||
| 150 | +# 步骤 3b: 用户取消 → 必须先保存草稿! | ||
| 151 | +python scripts/cli.py save-draft | ||
| 83 | ``` | 152 | ``` |
| 84 | 153 | ||
| 154 | +> ⚠️ **用户取消时必须调用 `save-draft`**,不得直接关闭 tab 或结束流程。 | ||
| 155 | +> 直接关闭 tab 会导致内容丢失,草稿不会保存到小红书草稿箱。 | ||
| 156 | + | ||
| 85 | 视频分步发布: | 157 | 视频分步发布: |
| 86 | 158 | ||
| 87 | ```bash | 159 | ```bash |
| @@ -95,10 +167,15 @@ python scripts/cli.py fill-publish-video \ | @@ -95,10 +167,15 @@ python scripts/cli.py fill-publish-video \ | ||
| 95 | 167 | ||
| 96 | # 步骤 2: 用户确认 | 168 | # 步骤 2: 用户确认 |
| 97 | 169 | ||
| 98 | -# 步骤 3: 点击发布 | 170 | +# 步骤 3a: 用户确认发布 |
| 99 | python scripts/cli.py click-publish | 171 | python scripts/cli.py click-publish |
| 172 | + | ||
| 173 | +# 步骤 3b: 用户取消 → 必须先保存草稿! | ||
| 174 | +python scripts/cli.py save-draft | ||
| 100 | ``` | 175 | ``` |
| 101 | 176 | ||
| 177 | +> ⚠️ **用户取消时必须调用 `save-draft`**,不得直接关闭 tab 或结束流程。 | ||
| 178 | + | ||
| 102 | #### 一步到位发布(快捷方式) | 179 | #### 一步到位发布(快捷方式) |
| 103 | 180 | ||
| 104 | ```bash | 181 | ```bash |
| @@ -247,3 +324,4 @@ python scripts/cli.py click-publish | @@ -247,3 +324,4 @@ python scripts/cli.py click-publish | ||
| 247 | - **标题过长**:自动缩短标题,保持语义。 | 324 | - **标题过长**:自动缩短标题,保持语义。 |
| 248 | - **页面选择器失效**:提示检查脚本中的选择器定义。 | 325 | - **页面选择器失效**:提示检查脚本中的选择器定义。 |
| 249 | - **模板加载超时**:长文模式下模板可能加载缓慢,等待 15 秒后超时。 | 326 | - **模板加载超时**:长文模式下模板可能加载缓慢,等待 15 秒后超时。 |
| 327 | +- **用户取消发布**:必须运行 `save-draft` 保存草稿,再告知用户已保存到草稿箱,不得直接关闭 tab。 |
-
Please register or login to post a comment