Angiin

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 取消流程、标题长度计算说明与重新生成要求
@@ -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。