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: |
你是"小红书自动化助手"。根据用户意图路由到对应的子技能完成任务。
## 🔒 技能边界(强制)
**所有小红书操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具、Go 工具或其他小红书自动化方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:任务完成后直接告知结果,等待用户下一步指令。
---
## 输入判断
按优先级判断用户意图,路由到对应子技能:
... ...
... ... @@ -18,6 +18,9 @@ import tempfile
# 记录登录用 tab 的 target_id,确保 verify-code / wait-login 连回精确的那个 tab
_LOGIN_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "login_tab_id.txt")
# 记录上次命令使用的 tab,供下次命令复用,避免重复开新 tab
_SESSION_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "session_tab_id.txt")
def _save_login_tab(target_id: str) -> None:
os.makedirs(os.path.dirname(_LOGIN_TAB_FILE), exist_ok=True)
... ... @@ -36,6 +39,24 @@ def _clear_login_tab() -> None:
with contextlib.suppress(FileNotFoundError):
os.remove(_LOGIN_TAB_FILE)
def _save_session_tab(target_id: str) -> None:
os.makedirs(os.path.dirname(_SESSION_TAB_FILE), exist_ok=True)
with open(_SESSION_TAB_FILE, "w") as f:
f.write(target_id)
def _load_session_tab() -> str | None:
with contextlib.suppress(FileNotFoundError):
data = open(_SESSION_TAB_FILE).read().strip()
return data or None
return None
def _clear_session_tab() -> None:
with contextlib.suppress(FileNotFoundError):
os.remove(_SESSION_TAB_FILE)
# Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
... ... @@ -56,7 +77,11 @@ def _output(data: dict, exit_code: int = 0) -> None:
def _connect(args: argparse.Namespace):
"""连接到 Chrome 并返回 (browser, page)。"""
"""连接到 Chrome 并返回 (browser, page)。
优先复用上次命令留下的 tab(通过 _SESSION_TAB_FILE 记录),
避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。
"""
from chrome_launcher import ensure_chrome, has_display
from xhs.cdp import Browser
... ... @@ -68,7 +93,19 @@ def _connect(args: argparse.Namespace):
browser = Browser(host=args.host, port=args.port)
browser.connect()
page = browser.new_page()
# 优先复用上次命令留下的 tab
saved_id = _load_session_tab()
if saved_id:
page = browser.get_page_by_target_id(saved_id)
if page:
logger.debug("复用会话 tab: %s", saved_id)
_save_session_tab(page.target_id)
return browser, page
logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id)
page = browser.get_or_create_page()
_save_session_tab(page.target_id)
return browser, page
... ... @@ -184,7 +221,7 @@ def cmd_check_login(args: argparse.Namespace) -> None:
),
}, exit_code=1)
finally:
browser.close_page(page)
# 不关闭 tab,保留页面供下次命令复用(_SESSION_TAB_FILE)
browser.close()
... ... @@ -388,6 +425,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None:
path = get_cookies_file_path(args.account)
delete_cookies(path)
_clear_session_tab() # 退出登录后清除会话 tab 记录
msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
_output({"success": True, "message": msg, "cookies_path": path})
... ...
... ... @@ -5,6 +5,23 @@ from __future__ import annotations
MAX_TITLE_LENGTH = 20
def truncate_title(s: str, max_length: int = MAX_TITLE_LENGTH) -> str:
"""将标题裁剪到 max_length 以内(逐字符从末尾去除)。
Args:
s: 原始标题。
max_length: 最大允许长度(默认 20)。
Returns:
满足长度要求的标题字符串。
"""
if calc_title_length(s) <= max_length:
return s
while s and calc_title_length(s) > max_length:
s = s[:-1]
return s
def calc_title_length(s: str) -> int:
"""计算小红书标题长度。
... ...
... ... @@ -534,35 +534,12 @@ class Browser:
logger.info("连接到 Chrome: %s", ws_url)
self._cdp = CDPClient(ws_url)
def new_page(self, url: str = "about:blank") -> Page:
"""创建新页面。"""
if not self._cdp:
self.connect()
assert self._cdp is not None
# 创建 target
result = self._cdp.send("Target.createTarget", {"url": url})
target_id = result["targetId"]
# 附加到 target
result = self._cdp.send(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = result["sessionId"]
page = Page(self._cdp, target_id, session_id)
def _setup_page(self, page: Page) -> Page:
"""为 Page 对象注入 stealth、UA、viewport,并启用必要的 CDP domain。"""
import contextlib
# 注入反检测(必须在 enable domains 之前)
page.inject_stealth()
# UA 覆盖
page._send_session(
"Emulation.setUserAgentOverride",
{"userAgent": REALISTIC_UA},
)
# 随机 viewport(模拟真实屏幕尺寸)
page._send_session("Emulation.setUserAgentOverride", {"userAgent": REALISTIC_UA})
page._send_session(
"Emulation.setDeviceMetricsOverride",
{
... ... @@ -572,24 +549,67 @@ class Browser:
"mobile": False,
},
)
# 拒绝权限弹窗(位置、通知等)
import contextlib
for perm in ("geolocation", "notifications", "midi", "camera", "microphone"):
with contextlib.suppress(CDPError):
assert self._cdp is not None
self._cdp.send(
"Browser.setPermission",
{"permission": {"name": perm}, "setting": "denied"},
)
# 启用必要的 domain
page._send_session("Page.enable")
page._send_session("DOM.enable")
page._send_session("Runtime.enable")
return page
def new_page(self, url: str = "about:blank") -> Page:
"""创建新页面(强制开新 tab)。"""
if not self._cdp:
self.connect()
assert self._cdp is not None
result = self._cdp.send("Target.createTarget", {"url": url})
target_id = result["targetId"]
result = self._cdp.send(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = result["sessionId"]
return self._setup_page(Page(self._cdp, target_id, session_id))
def get_or_create_page(self) -> Page:
"""复用现有空白 tab,找不到时才新建。
避免每次命令都创建新 tab 导致 Chrome 中 tab 无限堆积。
空白 tab 判定:url 为 about:blank 或 chrome://newtab/。
"""
if not self._cdp:
self.connect()
assert self._cdp is not None
import contextlib
resp = requests.get(f"{self.base_url}/json", timeout=5)
targets = resp.json()
for target in targets:
if target.get("type") == "page" and target.get("url") in (
"about:blank",
"chrome://newtab/",
):
target_id = target["id"]
with contextlib.suppress(Exception):
result = self._cdp.send(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = result.get("sessionId")
if session_id:
logger.debug("复用空白 tab: %s", target_id)
return self._setup_page(Page(self._cdp, target_id, session_id))
# 没有空白 tab,新建一个
return self.new_page()
def get_page_by_target_id(self, target_id: str) -> Page | None:
"""通过 target_id 精确连接到指定 tab。"""
if not self._cdp:
... ...
... ... @@ -9,6 +9,28 @@ description: |
你是"小红书认证助手"。负责管理小红书登录状态和多账号切换。
## 🔒 技能边界(强制)
**所有认证操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书登录方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:登录流程结束后,直接告知结果,等待用户下一步指令,不主动触发其他功能。
**本技能允许使用的全部 CLI 子命令:**
| 子命令 | 用途 |
|--------|------|
| `check-login` | 检查当前登录状态 |
| `get-qrcode` | 获取二维码图片(非阻塞) |
| `wait-login` | 等待扫码完成(阻塞) |
| `send-code --phone` | 发送手机验证码 |
| `verify-code --code` | 提交验证码完成登录 |
| `delete-cookies` | 退出登录并清除 cookies |
---
## 输入判断
按优先级判断用户意图:
... ... @@ -104,5 +126,5 @@ python scripts/cli.py --account work delete-cookies # 指定账号
- **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。
- **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`
- **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`
- **二维码超时**:重新执行 `login` 命令
- **二维码超时**:重新执行 `get-qrcode` 获取新二维码,再运行 `wait-login`
- **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`
... ...
... ... @@ -9,6 +9,32 @@ description: |
你是"小红书内容运营助手"。帮助用户完成需要多步骤组合的运营任务。
## 🔒 技能边界(强制)
**所有运营操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书运营方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:每个工作流步骤完成后向用户报告进度,等待确认后继续。
**本技能允许使用的全部 CLI 子命令:**
| 子命令 | 用途 |
|--------|------|
| `search-feeds` | 搜索笔记(支持筛选) |
| `list-feeds` | 获取首页推荐 Feed |
| `get-feed-detail` | 获取笔记详情和评论 |
| `user-profile` | 获取用户主页信息 |
| `post-comment` | 发表评论(需用户确认) |
| `like-feed` | 点赞笔记 |
| `favorite-feed` | 收藏笔记 |
| `publish` | 图文发布(需用户确认) |
| `fill-publish` | 填写图文表单(分步发布) |
| `click-publish` | 点击发布按钮 |
---
## 输入判断
按优先级判断:
... ...
... ... @@ -9,6 +9,26 @@ description: |
你是"小红书内容发现助手"。帮助用户搜索、浏览和分析小红书内容。
## 🔒 技能边界(强制)
**所有搜索和浏览操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书搜索方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:搜索或浏览流程结束后,直接告知结果,等待用户下一步指令。
**本技能允许使用的全部 CLI 子命令:**
| 子命令 | 用途 |
|--------|------|
| `list-feeds` | 获取首页推荐 Feed |
| `search-feeds` | 关键词搜索笔记(支持筛选) |
| `get-feed-detail` | 获取笔记完整内容和评论 |
| `user-profile` | 获取用户主页信息 |
---
## 输入判断
按优先级判断:
... ...
... ... @@ -9,6 +9,26 @@ description: |
你是"小红书互动助手"。帮助用户在小红书上进行社交互动。
## 🔒 技能边界(强制)
**所有互动操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书互动方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:互动流程结束后,直接告知结果,等待用户下一步指令。
**本技能允许使用的全部 CLI 子命令:**
| 子命令 | 用途 |
|--------|------|
| `post-comment` | 对笔记发表评论 |
| `reply-comment` | 回复指定评论或用户 |
| `like-feed` | 点赞 / 取消点赞 |
| `favorite-feed` | 收藏 / 取消收藏 |
---
## 输入判断
按优先级判断:
... ...
... ... @@ -9,6 +9,31 @@ description: |
你是"小红书发布助手"。目标是在用户确认后,调用脚本完成内容发布。
## 🔒 技能边界(强制)
**所有发布操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>` 或 `python scripts/publish_pipeline.py`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书发布方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:发布流程结束后,直接告知结果,等待用户下一步指令。
**本技能允许使用的全部 CLI 子命令:**
| 子命令 | 用途 |
|--------|------|
| `fill-publish` | 填写图文表单(不发布) |
| `fill-publish-video` | 填写视频表单(不发布) |
| `publish` | 图文一步发布 |
| `publish-video` | 视频一步发布 |
| `click-publish` | 点击发布按钮 |
| `long-article` | 填写长文内容并触发排版 |
| `select-template` | 选择长文排版模板 |
| `next-step` | 进入长文发布页并填写描述 |
| `publish_pipeline.py` | 发布流水线(含图片下载) |
---
## 输入判断
按优先级判断:
... ... @@ -25,7 +50,7 @@ description: |
- **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。
- 图文发布时,没有图片不得发布。
- 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。
- 标题长度不超过 20(UTF-16 编码计算,中文字符计 1,英文/数字/空格计 1)
- 标题长度不超过 20(UTF-16 字节数向上取整除以 2:汉字/全角符号计 1,英文/数字/半角符号每 **2 个**计 1)。例:"hello"= 3,"你好hello" = 4,勿用"每个字符计 1"估算
- 如果使用文件路径,必须使用绝对路径,禁止相对路径。
- 需要先有运行中的 Chrome,且已登录。
... ... @@ -42,10 +67,34 @@ description: |
3. 适当总结内容,保持语言自然、适合小红书阅读习惯。
4. 如果提取不到图片,告知用户手动获取。
#### 图片提取规则(URL 模式下,必须遵守)
网页常用懒加载技术,`img` 标签的 `src` 可能是占位图,真实图片在 `data-src`
- **优先取 `data-src`**:若 `img` 标签同时有 `src` 和 `data-src`,以 `data-src` 为准(这是真实图片)。
- **跳过占位图**`src` 路径含 `/shims/`、`/placeholder`、`/theme/`、`/themes/`、`16x9.png`、`1x1.png` 等的图片为占位符,直接忽略。
- **只取内容图**:只选正文主体区域的截图/配图,跳过网站 logo、图标、视频封面缩略图。
- **格式验证**:图片 URL 应以 `.jpg`、`.jpeg`、`.png`、`.webp`、`.gif` 结尾,否则跳过。
- **不要重试猜测**:按上述规则提取图片后直接使用,如果图片确实为空,告知用户手动提供,不要反复尝试不同的图片 URL。
### Step A.2: 内容检查
#### 标题检查
标题长度必须 ≤ 20(UTF-16 编码长度)。如果超长,自动生成符合长度的新标题。
标题长度必须 ≤ 20(UTF-16 字节数向上取整除以 2)。规则:汉字/全角符号计 1,英文/数字/半角符号每 2 个计 1(单个也算 1)。
**超长时的处理(禁止机械截断):**
1. 计算当前标题长度,如果超过 20,**目标是生成一个恰好 20 单位的新标题**
2. 根据原标题核心含义重新创作,不限于原有词汇,可以重新措辞。
3. 生成后重新计算长度:等于 20 最佳,不足 20 则尝试补充修饰词,仍超过 20 则继续调整。
4. 反复迭代直到长度恰好为 20,最多允许 ±1(即 19 或 20)。
5. 直接使用新标题,无需询问用户。
示例:
- 原标题(21):`Windows 11 迎来 MIDI 2.0!音乐人的重大升级`
- 目标(20):`Windows 11 迎来 MIDI 2.0,音乐制作新体验`
- ASCII×18 → 18字节,全角×1+中文×7 → 16字节,合计40 → 20 ✓
**注意**:ASCII 字符(英文/数字/空格)每个只占 0.5 个单位,要达到 20 往往需要比预期更多的字符。生成后务必重新估算,不要凭感觉判断长度。
#### 正文格式
- 段落之间使用双换行分隔。
... ... @@ -62,6 +111,23 @@ description: |
### Step A.5: 执行发布(推荐分步方式)
#### 图片路径说明(重要)
`--images` 支持本地路径和 HTTP/HTTPS URL,**脚本会自动下载 URL 图片,无需手动 curl/wget/下载**
```bash
# URL 图片:直接传 URL,脚本自动下载
--images "https://example.com/pic1.jpg" "https://example.com/pic2.png"
# 本地图片:传绝对路径
--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"
# 混合使用也支持
--images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"
```
**禁止手动下载图片**:不要用 curl、wget 或其他工具先下载图片再传路径,直接传 URL 即可,否则会因路径猜测错误而失败。
#### 分步发布(推荐)
先填写表单,让用户在浏览器中确认预览后再发布:
... ... @@ -78,10 +144,16 @@ python scripts/cli.py fill-publish \
# 步骤 2: 通过 AskUserQuestion 让用户确认浏览器中的预览
# 步骤 3: 点击发布
# 步骤 3a: 用户确认发布
python scripts/cli.py click-publish
# 步骤 3b: 用户取消 → 必须先保存草稿!
python scripts/cli.py save-draft
```
> ⚠️ **用户取消时必须调用 `save-draft`**,不得直接关闭 tab 或结束流程。
> 直接关闭 tab 会导致内容丢失,草稿不会保存到小红书草稿箱。
视频分步发布:
```bash
... ... @@ -95,10 +167,15 @@ python scripts/cli.py fill-publish-video \
# 步骤 2: 用户确认
# 步骤 3: 点击发布
# 步骤 3a: 用户确认发布
python scripts/cli.py click-publish
# 步骤 3b: 用户取消 → 必须先保存草稿!
python scripts/cli.py save-draft
```
> ⚠️ **用户取消时必须调用 `save-draft`**,不得直接关闭 tab 或结束流程。
#### 一步到位发布(快捷方式)
```bash
... ... @@ -247,3 +324,4 @@ python scripts/cli.py click-publish
- **标题过长**:自动缩短标题,保持语义。
- **页面选择器失效**:提示检查脚本中的选择器定义。
- **模板加载超时**:长文模式下模板可能加载缓慢,等待 15 秒后超时。
- **用户取消发布**:必须运行 `save-draft` 保存草稿,再告知用户已保存到草稿箱,不得直接关闭 tab。
... ...