Angiin
Committed by GitHub

Merge pull request #20 from autoclaw-cc/feat/multi-account-port-isolation

- 增加多账号功能,使用不同的端口对应不同的账号
- 多账号操作:执行任务前,和用户确认在什么账号上操作
- 修复手机验证码体验:使用手机验证码登录时,流程过长,会导致验证码失败
@@ -21,7 +21,7 @@ uv run pytest # 运行测试 @@ -21,7 +21,7 @@ uv run pytest # 运行测试
21 双层结构:`scripts/` 是 Python CDP 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。 21 双层结构:`scripts/` 是 Python CDP 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。
22 22
23 - `scripts/xhs/` — 核心自动化库(模块化,每个功能一个文件) 23 - `scripts/xhs/` — 核心自动化库(模块化,每个功能一个文件)
24 -- `scripts/cli.py` — 统一 CLI 入口,19 个子命令,JSON 结构化输出 24 +- `scripts/cli.py` — 统一 CLI 入口,23 个子命令,JSON 结构化输出
25 - `scripts/publish_pipeline.py` — 发布编排器(含图片下载和登录检查) 25 - `scripts/publish_pipeline.py` — 发布编排器(含图片下载和登录检查)
26 - `skills/*/SKILL.md` — 指导 Claude 如何调用 scripts/ 26 - `skills/*/SKILL.md` — 指导 Claude 如何调用 scripts/
27 27
@@ -74,3 +74,7 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima @@ -74,3 +74,7 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima
74 | `long-article` | — | 长文发布(填写+排版) | 74 | `long-article` | — | 长文发布(填写+排版) |
75 | `select-template` | — | 长文发布(选择模板) | 75 | `select-template` | — | 长文发布(选择模板) |
76 | `next-step` | — | 长文发布(下一步+描述) | 76 | `next-step` | — | 长文发布(下一步+描述) |
  77 +| `add-account` | — | 账号管理(添加,自动分配端口) |
  78 +| `list-accounts` | — | 账号管理(列出所有) |
  79 +| `remove-account` | — | 账号管理(删除) |
  80 +| `set-default-account` | — | 账号管理(设置默认) |
@@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
13 _CONFIG_DIR = Path.home() / ".xhs" 13 _CONFIG_DIR = Path.home() / ".xhs"
14 _ACCOUNTS_FILE = _CONFIG_DIR / "accounts.json" 14 _ACCOUNTS_FILE = _CONFIG_DIR / "accounts.json"
15 15
  16 +# 命名账号端口起始值(默认账号使用 9222)
  17 +_NAMED_PORT_START = 9223
  18 +
16 19
17 def _load_config() -> dict: 20 def _load_config() -> dict:
18 """加载账号配置。""" 21 """加载账号配置。"""
@@ -41,20 +44,25 @@ def list_accounts() -> list[dict]: @@ -41,20 +44,25 @@ def list_accounts() -> list[dict]:
41 "name": name, 44 "name": name,
42 "description": info.get("description", ""), 45 "description": info.get("description", ""),
43 "is_default": name == default, 46 "is_default": name == default,
44 - "profile_dir": _get_profile_dir(name), 47 + "profile_dir": get_profile_dir(name),
  48 + "port": info.get("port", _NAMED_PORT_START),
45 } 49 }
46 ) 50 )
47 return result 51 return result
48 52
49 53
50 def add_account(name: str, description: str = "") -> None: 54 def add_account(name: str, description: str = "") -> None:
51 - """添加账号。""" 55 + """添加账号,自动分配独立端口(从 _NAMED_PORT_START 递增)。"""
52 config = _load_config() 56 config = _load_config()
53 accounts = config.setdefault("accounts", {}) 57 accounts = config.setdefault("accounts", {})
54 if name in accounts: 58 if name in accounts:
55 raise ValueError(f"账号 '{name}' 已存在") 59 raise ValueError(f"账号 '{name}' 已存在")
56 60
57 - accounts[name] = {"description": description} 61 + # 自动分配端口:取已有端口的最大值(至少 _NAMED_PORT_START - 1)加 1
  62 + existing_ports = {info.get("port", _NAMED_PORT_START) for info in accounts.values()}
  63 + port = max(existing_ports | {_NAMED_PORT_START - 1}) + 1
  64 +
  65 + accounts[name] = {"description": description, "port": port}
58 66
59 # 如果是第一个账号,设为默认 67 # 如果是第一个账号,设为默认
60 if not config.get("default"): 68 if not config.get("default"):
@@ -63,10 +71,10 @@ def add_account(name: str, description: str = "") -> None: @@ -63,10 +71,10 @@ def add_account(name: str, description: str = "") -> None:
63 _save_config(config) 71 _save_config(config)
64 72
65 # 创建 Profile 目录 73 # 创建 Profile 目录
66 - profile_dir = _get_profile_dir(name) 74 + profile_dir = get_profile_dir(name)
67 os.makedirs(profile_dir, exist_ok=True) 75 os.makedirs(profile_dir, exist_ok=True)
68 76
69 - logger.info("添加账号: %s", name) 77 + logger.info("添加账号: %s (port=%d)", name, port)
70 78
71 79
72 def remove_account(name: str) -> None: 80 def remove_account(name: str) -> None:
@@ -98,12 +106,37 @@ def set_default_account(name: str) -> None: @@ -98,12 +106,37 @@ def set_default_account(name: str) -> None:
98 logger.info("默认账号设置为: %s", name) 106 logger.info("默认账号设置为: %s", name)
99 107
100 108
  109 +def update_account_description(name: str, description: str) -> None:
  110 + """更新账号描述(通常用于存储平台昵称)。"""
  111 + config = _load_config()
  112 + accounts = config.get("accounts", {})
  113 + if name not in accounts:
  114 + raise ValueError(f"账号 '{name}' 不存在")
  115 + accounts[name]["description"] = description
  116 + _save_config(config)
  117 + logger.info("账号 %s 描述已更新: %s", name, description)
  118 +
  119 +
101 def get_default_account() -> str: 120 def get_default_account() -> str:
102 """获取默认账号名称。""" 121 """获取默认账号名称。"""
103 config = _load_config() 122 config = _load_config()
104 return config.get("default", "") 123 return config.get("default", "")
105 124
106 125
107 -def _get_profile_dir(account: str) -> str: 126 +def get_profile_dir(account: str) -> str:
108 """获取账号的 Chrome Profile 目录。""" 127 """获取账号的 Chrome Profile 目录。"""
109 return str(_CONFIG_DIR / "accounts" / account / "chrome-profile") 128 return str(_CONFIG_DIR / "accounts" / account / "chrome-profile")
  129 +
  130 +
  131 +def _get_profile_dir(account: str) -> str:
  132 + """获取账号的 Chrome Profile 目录(别名,向后兼容)。"""
  133 + return get_profile_dir(account)
  134 +
  135 +
  136 +def get_account_port(name: str) -> int:
  137 + """获取指定账号的 Chrome 调试端口。"""
  138 + config = _load_config()
  139 + accounts = config.get("accounts", {})
  140 + if name not in accounts:
  141 + raise ValueError(f"账号 '{name}' 不存在")
  142 + return accounts[name].get("port", _NAMED_PORT_START)
@@ -15,47 +15,52 @@ import os @@ -15,47 +15,52 @@ import os
15 import sys 15 import sys
16 import tempfile 16 import tempfile
17 17
18 -# 记录登录用 tab 的 target_id,确保 verify-code / wait-login 连回精确的那个 tab  
19 -_LOGIN_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "login_tab_id.txt") 18 +def _session_tab_file(port: int) -> str:
  19 + """返回指定端口的 session tab 文件路径(每账号独立隔离)。"""
  20 + return os.path.join(tempfile.gettempdir(), "xhs", f"session_tab_{port}.txt")
20 21
21 -# 记录上次命令使用的 tab,供下次命令复用,避免重复开新 tab  
22 -_SESSION_TAB_FILE = os.path.join(tempfile.gettempdir(), "xhs", "session_tab_id.txt")  
23 22
  23 +def _login_tab_file(port: int) -> str:
  24 + """返回指定端口的 login tab 文件路径(每账号独立隔离)。"""
  25 + return os.path.join(tempfile.gettempdir(), "xhs", f"login_tab_{port}.txt")
24 26
25 -def _save_login_tab(target_id: str) -> None:  
26 - os.makedirs(os.path.dirname(_LOGIN_TAB_FILE), exist_ok=True)  
27 - with open(_LOGIN_TAB_FILE, "w") as f: 27 +
  28 +def _save_login_tab(target_id: str, port: int) -> None:
  29 + path = _login_tab_file(port)
  30 + os.makedirs(os.path.dirname(path), exist_ok=True)
  31 + with open(path, "w") as f:
28 f.write(target_id) 32 f.write(target_id)
29 33
30 34
31 -def _load_login_tab() -> str | None: 35 +def _load_login_tab(port: int) -> str | None:
32 with contextlib.suppress(FileNotFoundError): 36 with contextlib.suppress(FileNotFoundError):
33 - data = open(_LOGIN_TAB_FILE).read().strip() 37 + data = open(_login_tab_file(port)).read().strip()
34 return data or None 38 return data or None
35 return None 39 return None
36 40
37 41
38 -def _clear_login_tab() -> None: 42 +def _clear_login_tab(port: int) -> None:
39 with contextlib.suppress(FileNotFoundError): 43 with contextlib.suppress(FileNotFoundError):
40 - os.remove(_LOGIN_TAB_FILE) 44 + os.remove(_login_tab_file(port))
41 45
42 46
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: 47 +def _save_session_tab(target_id: str, port: int) -> None:
  48 + path = _session_tab_file(port)
  49 + os.makedirs(os.path.dirname(path), exist_ok=True)
  50 + with open(path, "w") as f:
46 f.write(target_id) 51 f.write(target_id)
47 52
48 53
49 -def _load_session_tab() -> str | None: 54 +def _load_session_tab(port: int) -> str | None:
50 with contextlib.suppress(FileNotFoundError): 55 with contextlib.suppress(FileNotFoundError):
51 - data = open(_SESSION_TAB_FILE).read().strip() 56 + data = open(_session_tab_file(port)).read().strip()
52 return data or None 57 return data or None
53 return None 58 return None
54 59
55 60
56 -def _clear_session_tab() -> None: 61 +def _clear_session_tab(port: int) -> None:
57 with contextlib.suppress(FileNotFoundError): 62 with contextlib.suppress(FileNotFoundError):
58 - os.remove(_SESSION_TAB_FILE) 63 + os.remove(_session_tab_file(port))
59 64
60 # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8 65 # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8
61 if sys.stdout and hasattr(sys.stdout, "reconfigure"): 66 if sys.stdout and hasattr(sys.stdout, "reconfigure"):
@@ -76,16 +81,51 @@ def _output(data: dict, exit_code: int = 0) -> None: @@ -76,16 +81,51 @@ def _output(data: dict, exit_code: int = 0) -> None:
76 sys.exit(exit_code) 81 sys.exit(exit_code)
77 82
78 83
  84 +def _update_account_nickname(args: argparse.Namespace, page) -> None:
  85 + """登录成功后,将平台昵称写入账号描述(best-effort,失败不影响登录结果)。"""
  86 + if not getattr(args, "account", ""):
  87 + return
  88 + import sys as _sys
  89 +
  90 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  91 + import account_manager
  92 + from xhs.login import get_current_user_nickname
  93 +
  94 + try:
  95 + nickname = get_current_user_nickname(page)
  96 + if nickname:
  97 + account_manager.update_account_description(args.account, nickname)
  98 + logger.info("账号 %s 昵称已更新: %s", args.account, nickname)
  99 + except Exception as e:
  100 + logger.warning("更新账号昵称失败: %s", e)
  101 +
  102 +
  103 +def _resolve_account(args: argparse.Namespace) -> str | None:
  104 + """解析 --account 参数,更新 args.port,返回 user_data_dir(无账号时返回 None)。"""
  105 + if not getattr(args, "account", ""):
  106 + return None
  107 + import sys as _sys
  108 +
  109 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  110 + import account_manager
  111 +
  112 + name = args.account
  113 + args.port = account_manager.get_account_port(name)
  114 + return account_manager.get_profile_dir(name)
  115 +
  116 +
79 def _connect(args: argparse.Namespace): 117 def _connect(args: argparse.Namespace):
80 """连接到 Chrome 并返回 (browser, page)。 118 """连接到 Chrome 并返回 (browser, page)。
81 119
82 - 优先复用上次命令留下的 tab(通过 _SESSION_TAB_FILE 记录), 120 + 优先复用上次命令留下的 tab(通过端口隔离的 session tab 文件记录),
83 避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。 121 避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。
84 """ 122 """
85 from chrome_launcher import ensure_chrome, has_display 123 from chrome_launcher import ensure_chrome, has_display
86 from xhs.cdp import Browser 124 from xhs.cdp import Browser
87 125
88 - if not ensure_chrome(port=args.port, headless=not has_display()): 126 + user_data_dir = _resolve_account(args)
  127 +
  128 + if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
89 _output( 129 _output(
90 {"success": False, "error": "无法启动 Chrome,请检查 Chrome 是否已安装"}, 130 {"success": False, "error": "无法启动 Chrome,请检查 Chrome 是否已安装"},
91 exit_code=2, 131 exit_code=2,
@@ -95,32 +135,34 @@ def _connect(args: argparse.Namespace): @@ -95,32 +135,34 @@ def _connect(args: argparse.Namespace):
95 browser.connect() 135 browser.connect()
96 136
97 # 优先复用上次命令留下的 tab 137 # 优先复用上次命令留下的 tab
98 - saved_id = _load_session_tab() 138 + saved_id = _load_session_tab(args.port)
99 if saved_id: 139 if saved_id:
100 page = browser.get_page_by_target_id(saved_id) 140 page = browser.get_page_by_target_id(saved_id)
101 if page: 141 if page:
102 logger.debug("复用会话 tab: %s", saved_id) 142 logger.debug("复用会话 tab: %s", saved_id)
103 - _save_session_tab(page.target_id) 143 + _save_session_tab(page.target_id, args.port)
104 return browser, page 144 return browser, page
105 logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id) 145 logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id)
106 146
107 page = browser.get_or_create_page() 147 page = browser.get_or_create_page()
108 - _save_session_tab(page.target_id) 148 + _save_session_tab(page.target_id, args.port)
109 return browser, page 149 return browser, page
110 150
111 151
112 def _connect_saved_tab(args: argparse.Namespace): 152 def _connect_saved_tab(args: argparse.Namespace):
113 - """连接到登录流程中记录的精确 tab(via _LOGIN_TAB_FILE),回退到第一个非空白 tab。""" 153 + """连接到登录流程中记录的精确 tab,回退到第一个非空白 tab。"""
114 from chrome_launcher import ensure_chrome, has_display 154 from chrome_launcher import ensure_chrome, has_display
115 from xhs.cdp import Browser 155 from xhs.cdp import Browser
116 156
117 - if not ensure_chrome(port=args.port, headless=not has_display()): 157 + user_data_dir = _resolve_account(args)
  158 +
  159 + if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
118 _output({"success": False, "error": "无法连接到 Chrome"}, exit_code=2) 160 _output({"success": False, "error": "无法连接到 Chrome"}, exit_code=2)
119 161
120 browser = Browser(host=args.host, port=args.port) 162 browser = Browser(host=args.host, port=args.port)
121 browser.connect() 163 browser.connect()
122 164
123 - target_id = _load_login_tab() 165 + target_id = _load_login_tab(args.port)
124 if target_id: 166 if target_id:
125 page = browser.get_page_by_target_id(target_id) 167 page = browser.get_page_by_target_id(target_id)
126 if page: 168 if page:
@@ -141,7 +183,9 @@ def _connect_existing(args: argparse.Namespace): @@ -141,7 +183,9 @@ def _connect_existing(args: argparse.Namespace):
141 from chrome_launcher import ensure_chrome, has_display 183 from chrome_launcher import ensure_chrome, has_display
142 from xhs.cdp import Browser 184 from xhs.cdp import Browser
143 185
144 - if not ensure_chrome(port=args.port, headless=not has_display()): 186 + user_data_dir = _resolve_account(args)
  187 +
  188 + if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
145 _output( 189 _output(
146 {"success": False, "error": "无法连接到 Chrome"}, 190 {"success": False, "error": "无法连接到 Chrome"},
147 exit_code=2, 191 exit_code=2,
@@ -244,6 +288,8 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -244,6 +288,8 @@ def cmd_login(args: argparse.Namespace) -> None:
244 ) 288 )
245 ) 289 )
246 success = wait_for_login(page, timeout=120) 290 success = wait_for_login(page, timeout=120)
  291 + if success:
  292 + _update_account_nickname(args, page)
247 _output( 293 _output(
248 {"logged_in": success, "message": "登录成功" if success else "登录超时"}, 294 {"logged_in": success, "message": "登录成功" if success else "登录超时"},
249 exit_code=0 if success else 2, 295 exit_code=0 if success else 2,
@@ -293,7 +339,7 @@ def cmd_phone_login(args: argparse.Namespace) -> None: @@ -293,7 +339,7 @@ def cmd_phone_login(args: argparse.Namespace) -> None:
293 exit_code=0 if success else 2, 339 exit_code=0 if success else 2,
294 ) 340 )
295 finally: 341 finally:
296 - browser.close_page(page) 342 + # 不关闭 tab——与 verify-code 一致,保留页面供重试
297 browser.close() 343 browser.close()
298 344
299 345
@@ -318,8 +364,10 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -318,8 +364,10 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
318 364
319 qrcode_path = save_qrcode_to_file(png_bytes) 365 qrcode_path = save_qrcode_to_file(png_bytes)
320 366
321 - # 记录 tab,供 wait-login 精确reconnect  
322 - _save_login_tab(page.target_id) 367 + # 记录 login tab,供 wait-login 精确 reconnect
  368 + _save_login_tab(page.target_id, args.port)
  369 + # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
  370 + _clear_session_tab(args.port)
323 371
324 # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码 372 # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码
325 browser.close() 373 browser.close()
@@ -340,7 +388,8 @@ def cmd_wait_login(args: argparse.Namespace) -> None: @@ -340,7 +388,8 @@ def cmd_wait_login(args: argparse.Namespace) -> None:
340 try: 388 try:
341 success = wait_for_login(page, timeout=args.timeout) 389 success = wait_for_login(page, timeout=args.timeout)
342 if success: 390 if success:
343 - _clear_login_tab() 391 + _clear_login_tab(args.port)
  392 + _update_account_nickname(args, page)
344 _output( 393 _output(
345 { 394 {
346 "logged_in": success, 395 "logged_in": success,
@@ -366,8 +415,10 @@ def cmd_send_code(args: argparse.Namespace) -> None: @@ -366,8 +415,10 @@ def cmd_send_code(args: argparse.Namespace) -> None:
366 _output({"logged_in": True, "message": "已登录,无需重新登录"}) 415 _output({"logged_in": True, "message": "已登录,无需重新登录"})
367 return 416 return
368 417
369 - # 记录 tab,供 verify-code 精确 reconnect  
370 - _save_login_tab(page.target_id) 418 + # 记录 login tab,供 verify-code 精确 reconnect
  419 + _save_login_tab(page.target_id, args.port)
  420 + # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
  421 + _clear_session_tab(args.port)
371 _output({ 422 _output({
372 "status": "code_sent", 423 "status": "code_sent",
373 "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>", 424 "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>",
@@ -393,13 +444,14 @@ def cmd_verify_code(args: argparse.Namespace) -> None: @@ -393,13 +444,14 @@ def cmd_verify_code(args: argparse.Namespace) -> None:
393 try: 444 try:
394 success = submit_phone_code(page, args.code) 445 success = submit_phone_code(page, args.code)
395 if success: 446 if success:
396 - _clear_login_tab() 447 + _clear_login_tab(args.port)
  448 + _update_account_nickname(args, page)
397 _output( 449 _output(
398 {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, 450 {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
399 exit_code=0 if success else 2, 451 exit_code=0 if success else 2,
400 ) 452 )
401 finally: 453 finally:
402 - browser.close_page(page) 454 + # 不关闭 tab——成功后供后续命令复用,失败后用户可再次运行 verify-code 重试
403 browser.close() 455 browser.close()
404 456
405 457
@@ -420,7 +472,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None: @@ -420,7 +472,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None:
420 path = get_cookies_file_path(args.account) 472 path = get_cookies_file_path(args.account)
421 delete_cookies(path) 473 delete_cookies(path)
422 474
423 - _clear_session_tab() # 退出登录后清除会话 tab 记录 475 + _clear_session_tab(args.port) # 退出登录后清除会话 tab 记录
424 msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件" 476 msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
425 _output({"success": True, "message": msg, "cookies_path": path}) 477 _output({"success": True, "message": msg, "cookies_path": path})
426 478
@@ -817,6 +869,55 @@ def cmd_publish_video(args: argparse.Namespace) -> None: @@ -817,6 +869,55 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
817 browser.close() 869 browser.close()
818 870
819 871
  872 +# ========== 账号管理子命令 ==========
  873 +
  874 +
  875 +def cmd_add_account(args: argparse.Namespace) -> None:
  876 + """添加命名账号,自动分配独立端口和 Chrome Profile。"""
  877 + import sys as _sys
  878 +
  879 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  880 + import account_manager
  881 +
  882 + account_manager.add_account(args.name, description=args.description or "")
  883 + port = account_manager.get_account_port(args.name)
  884 + profile = account_manager.get_profile_dir(args.name)
  885 + _output({"success": True, "name": args.name, "port": port, "profile_dir": profile})
  886 +
  887 +
  888 +def cmd_list_accounts(args: argparse.Namespace) -> None:
  889 + """列出所有命名账号。"""
  890 + import sys as _sys
  891 +
  892 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  893 + import account_manager
  894 +
  895 + accounts = account_manager.list_accounts()
  896 + _output({"accounts": accounts, "count": len(accounts)})
  897 +
  898 +
  899 +def cmd_remove_account(args: argparse.Namespace) -> None:
  900 + """删除命名账号。"""
  901 + import sys as _sys
  902 +
  903 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  904 + import account_manager
  905 +
  906 + account_manager.remove_account(args.name)
  907 + _output({"success": True, "name": args.name})
  908 +
  909 +
  910 +def cmd_set_default_account(args: argparse.Namespace) -> None:
  911 + """设置默认账号。"""
  912 + import sys as _sys
  913 +
  914 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  915 + import account_manager
  916 +
  917 + account_manager.set_default_account(args.name)
  918 + _output({"success": True, "default": args.name})
  919 +
  920 +
820 # ========== 参数解析 ========== 921 # ========== 参数解析 ==========
821 922
822 923
@@ -1001,6 +1102,26 @@ def build_parser() -> argparse.ArgumentParser: @@ -1001,6 +1102,26 @@ def build_parser() -> argparse.ArgumentParser:
1001 sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)") 1102 sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)")
1002 sub.set_defaults(func=cmd_save_draft) 1103 sub.set_defaults(func=cmd_save_draft)
1003 1104
  1105 + # add-account(添加命名账号)
  1106 + sub = subparsers.add_parser("add-account", help="添加命名账号,自动分配独立端口")
  1107 + sub.add_argument("--name", required=True, help="账号名称")
  1108 + sub.add_argument("--description", default="", help="账号描述(可选)")
  1109 + sub.set_defaults(func=cmd_add_account)
  1110 +
  1111 + # list-accounts(列出所有账号)
  1112 + sub = subparsers.add_parser("list-accounts", help="列出所有命名账号")
  1113 + sub.set_defaults(func=cmd_list_accounts)
  1114 +
  1115 + # remove-account(删除账号)
  1116 + sub = subparsers.add_parser("remove-account", help="删除命名账号")
  1117 + sub.add_argument("--name", required=True, help="账号名称")
  1118 + sub.set_defaults(func=cmd_remove_account)
  1119 +
  1120 + # set-default-account(设置默认账号)
  1121 + sub = subparsers.add_parser("set-default-account", help="设置默认账号")
  1122 + sub.add_argument("--name", required=True, help="账号名称")
  1123 + sub.set_defaults(func=cmd_set_default_account)
  1124 +
1004 return parser 1125 return parser
1005 1126
1006 1127
@@ -28,12 +28,28 @@ from .selectors import ( @@ -28,12 +28,28 @@ from .selectors import (
28 PHONE_INPUT, 28 PHONE_INPUT,
29 PHONE_LOGIN_SUBMIT, 29 PHONE_LOGIN_SUBMIT,
30 QRCODE_IMG, 30 QRCODE_IMG,
  31 + USER_NICKNAME,
  32 + USER_PROFILE_NAV_LINK,
31 ) 33 )
32 from .urls import EXPLORE_URL 34 from .urls import EXPLORE_URL
33 35
34 logger = logging.getLogger(__name__) 36 logger = logging.getLogger(__name__)
35 37
36 38
  39 +def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None:
  40 + """等待"获取验证码"按钮出现倒计时数字,确认验证码已发送。
  41 +
  42 + 轮询按钮文字直到包含数字(如 "60s"),超时则抛出 RateLimitError。
  43 + """
  44 + deadline = time.monotonic() + timeout
  45 + while time.monotonic() < deadline:
  46 + btn_text = page.get_element_text(GET_CODE_BUTTON) or ""
  47 + if any(ch.isdigit() for ch in btn_text):
  48 + return
  49 + time.sleep(0.3)
  50 + raise RateLimitError()
  51 +
  52 +
37 def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None: 53 def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:
38 """等待认证 UI 出现,替代固定延迟。 54 """等待认证 UI 出现,替代固定延迟。
39 55
@@ -47,6 +63,40 @@ def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None: @@ -47,6 +63,40 @@ def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:
47 time.sleep(0.2) 63 time.sleep(0.2)
48 64
49 65
  66 +def get_current_user_nickname(page: Page) -> str:
  67 + """获取当前登录用户的真实昵称,失败时返回空字符串(best-effort)。
  68 +
  69 + 流程:首页导航栏取个人主页 href → 导航过去 → 读 .user-name 文字。
  70 + """
  71 + try:
  72 + page.navigate(EXPLORE_URL)
  73 + page.wait_for_load()
  74 + _wait_for_auth_ui(page)
  75 + if not page.has_element(LOGIN_STATUS):
  76 + return ""
  77 +
  78 + # 从导航栏"我"的链接取个人主页 URL(含 /user/profile/<user_id>)
  79 + profile_href = page.evaluate(
  80 + f"document.querySelector({json.dumps(USER_PROFILE_NAV_LINK)})?.getAttribute('href') || ''"
  81 + )
  82 + if not profile_href:
  83 + return ""
  84 +
  85 + # 导航到个人主页读取真实昵称
  86 + profile_url = f"https://www.xiaohongshu.com{profile_href}"
  87 + page.navigate(profile_url)
  88 + page.wait_for_load()
  89 + page.wait_dom_stable()
  90 +
  91 + nickname = page.evaluate(
  92 + f"document.querySelector({json.dumps(USER_NICKNAME)})?.innerText?.trim() || ''"
  93 + )
  94 + return nickname or ""
  95 + except Exception:
  96 + logger.warning("获取用户昵称失败")
  97 + return ""
  98 +
  99 +
50 def check_login_status(page: Page) -> bool: 100 def check_login_status(page: Page) -> bool:
51 """检查登录状态。 101 """检查登录状态。
52 102
@@ -135,20 +185,26 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -135,20 +185,26 @@ def send_phone_code(page: Page, phone: str) -> bool:
135 """ 185 """
136 page.navigate(EXPLORE_URL) 186 page.navigate(EXPLORE_URL)
137 page.wait_for_load() 187 page.wait_for_load()
138 - sleep_random(1500, 2500)  
139 188
  189 + # 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待)
  190 + try:
  191 + page.wait_for_element(LOGIN_CONTAINER, timeout=10.0)
  192 + except Exception as exc:
  193 + # 可能已登录(没有登录容器),检查登录状态
140 if page.has_element(LOGIN_STATUS): 194 if page.has_element(LOGIN_STATUS):
141 return False 195 return False
  196 + raise RuntimeError("找不到登录表单") from exc
142 197
143 - # 等待登录弹窗出现  
144 - page.wait_for_element(LOGIN_CONTAINER, timeout=15.0)  
145 - sleep_random(500, 800) 198 + if page.has_element(LOGIN_STATUS):
  199 + return False
  200 +
  201 + sleep_random(200, 400)
146 202
147 # 点击手机号输入框并逐字输入 203 # 点击手机号输入框并逐字输入
148 page.click_element(PHONE_INPUT) 204 page.click_element(PHONE_INPUT)
149 sleep_random(200, 400) 205 sleep_random(200, 400)
150 page.type_text(phone, delay_ms=80) 206 page.type_text(phone, delay_ms=80)
151 - sleep_random(500, 800) 207 + sleep_random(200, 400)
152 208
153 # 先勾选用户协议,再点获取验证码 209 # 先勾选用户协议,再点获取验证码
154 if not page.has_element(AGREE_CHECKBOX_CHECKED): 210 if not page.has_element(AGREE_CHECKBOX_CHECKED):
@@ -157,12 +213,9 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -157,12 +213,9 @@ def send_phone_code(page: Page, phone: str) -> bool:
157 213
158 # 点击"获取验证码" 214 # 点击"获取验证码"
159 page.click_element(GET_CODE_BUTTON) 215 page.click_element(GET_CODE_BUTTON)
160 - sleep_random(2000, 2500)  
161 216
162 - # 检测按钮是否变为倒计时(成功发送后按钮文字会包含数字秒数)  
163 - btn_text = page.get_element_text(GET_CODE_BUTTON) or ""  
164 - if not any(ch.isdigit() for ch in btn_text):  
165 - raise RateLimitError() 217 + # 事件驱动:轮询按钮文字直到出现倒计时数字,替代固定 2-2.5s 等待
  218 + _wait_for_countdown(page)
166 219
167 logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:]) 220 logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:])
168 return True 221 return True
@@ -178,15 +231,27 @@ def submit_phone_code(page: Page, code: str) -> bool: @@ -178,15 +231,27 @@ def submit_phone_code(page: Page, code: str) -> bool:
178 Returns: 231 Returns:
179 True 登录成功,False 失败(超时或验证码错误)。 232 True 登录成功,False 失败(超时或验证码错误)。
180 """ 233 """
181 - # 点击验证码输入框并逐字输入 234 + # 点击验证码输入框,先清空再用 CDP 键盘事件逐字输入(isTrusted=true,React 能识别)
182 page.click_element(CODE_INPUT) 235 page.click_element(CODE_INPUT)
183 - sleep_random(300, 500)  
184 - page.type_text(code, delay_ms=100)  
185 - sleep_random(500, 800) 236 + sleep_random(100, 200)
  237 + page.evaluate(
  238 + f"""(() => {{
  239 + const el = document.querySelector({json.dumps(CODE_INPUT)});
  240 + if (el && el.value) {{
  241 + const setter = Object.getOwnPropertyDescriptor(
  242 + window.HTMLInputElement.prototype, 'value'
  243 + ).set;
  244 + setter.call(el, '');
  245 + el.dispatchEvent(new Event('input', {{ bubbles: true }}));
  246 + }}
  247 + }})()"""
  248 + )
  249 + page.type_text(code, delay_ms=0)
  250 + sleep_random(100, 200)
186 251
187 # 点击登录按钮 252 # 点击登录按钮
188 page.click_element(PHONE_LOGIN_SUBMIT) 253 page.click_element(PHONE_LOGIN_SUBMIT)
189 - sleep_random(1000, 2000) 254 + sleep_random(500, 1000)
190 255
191 # 检查是否有错误提示 256 # 检查是否有错误提示
192 err = page.get_element_text(LOGIN_ERR_MSG) 257 err = page.get_element_text(LOGIN_ERR_MSG)
@@ -91,3 +91,7 @@ LOGOUT_MENU_ITEM = 'div.menu-item[data-name="退出登录"]' @@ -91,3 +91,7 @@ LOGOUT_MENU_ITEM = 'div.menu-item[data-name="退出登录"]'
91 91
92 # ========== 用户主页 ========== 92 # ========== 用户主页 ==========
93 SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel" 93 SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel"
  94 +# 登录后导航栏"我"的链接(href 含 /user/profile/<user_id>)
  95 +USER_PROFILE_NAV_LINK = ".main-container .user .link-wrapper a.link-wrapper"
  96 +# 个人主页真实昵称
  97 +USER_NICKNAME = ".user-name"
@@ -39,6 +39,29 @@ metadata: @@ -39,6 +39,29 @@ metadata:
39 | `send-code --phone` | 发送手机验证码 | 39 | `send-code --phone` | 发送手机验证码 |
40 | `verify-code --code` | 提交验证码完成登录 | 40 | `verify-code --code` | 提交验证码完成登录 |
41 | `delete-cookies` | 退出登录并清除 cookies | 41 | `delete-cookies` | 退出登录并清除 cookies |
  42 +| `add-account --name` | 添加命名账号(自动分配端口) |
  43 +| `list-accounts` | 列出所有命名账号及端口 |
  44 +| `remove-account --name` | 删除命名账号 |
  45 +| `set-default-account --name` | 设置默认账号 |
  46 +
  47 +---
  48 +
  49 +## 账号选择(前置步骤)
  50 +
  51 +> **例外**:用户要求"添加账号 / 列出账号 / 删除账号 / 设置默认账号"时,**跳过此步骤**,直接执行对应管理命令。
  52 +
  53 +其余操作(检查登录、登录、退出登录)先运行:
  54 +
  55 +```bash
  56 +python scripts/cli.py list-accounts
  57 +```
  58 +
  59 +根据返回的 `count`
  60 +- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
  61 +- **1 个命名账号**:告知用户"将对账号 X 执行操作",直接加 `--account <名称>` 执行。
  62 +- **多个命名账号**:向用户展示列表,询问操作哪个账号,用 `--account <选择的名称>` 执行后续命令。
  63 +
  64 +账号选定后,本次操作全程固定该账号,**不重复询问**
42 65
43 --- 66 ---
44 67
@@ -102,14 +125,18 @@ python scripts/cli.py wait-login @@ -102,14 +125,18 @@ python scripts/cli.py wait-login
102 125
103 #### 方式 B:手机验证码登录(无界面服务器,分两步) 126 #### 方式 B:手机验证码登录(无界面服务器,分两步)
104 127
105 -**执行前必须先向用户索取手机号,不得自行假设或跳过此步。** 128 +**⚠️ 强制要求:必须先向用户确认手机号,即使上下文中已有手机号也不得跳过。**
  129 +- 用户可能要登录不同账号,手机号可能已变更。
  130 +- **禁止从历史对话、记忆或上下文中自动填入手机号。**
  131 +- **每次登录都必须明确向用户询问并得到确认后才能执行 `send-code`。**
106 132
107 -**第一步** — 向用户询问手机号,然后发送验证码: 133 +**第一步** — 向用户确认手机号,然后发送验证码:
108 134
109 -> 请先问用户:"请提供您的手机号(不含国家码,如 13800138000)",获得回复后再执行以下命令。 135 +> **必须先问用户**:"请提供您要登录的手机号(不含国家码,如 13800138000)"。
  136 +> 收到用户明确回复手机号后,才能执行以下命令。**不得跳过此步。**
110 137
111 ```bash 138 ```bash
112 -python scripts/cli.py send-code --phone <用户提供的手机号> 139 +python scripts/cli.py send-code --phone <用户确认的手机号>
113 ``` 140 ```
114 - 自动填写手机号、勾选用户协议、点击"获取验证码"。 141 - 自动填写手机号、勾选用户协议、点击"获取验证码"。
115 - Chrome 页面保持打开,等待下一步。 142 - Chrome 页面保持打开,等待下一步。
@@ -132,6 +159,41 @@ python scripts/cli.py delete-cookies @@ -132,6 +159,41 @@ python scripts/cli.py delete-cookies
132 python scripts/cli.py --account work delete-cookies # 指定账号 159 python scripts/cli.py --account work delete-cookies # 指定账号
133 ``` 160 ```
134 161
  162 +## 多账号工作流
  163 +
  164 +每个命名账号拥有独立端口(从 9223 起递增)和独立 Chrome Profile,账号之间完全隔离。
  165 +
  166 +### 添加账号
  167 +
  168 +```bash
  169 +python scripts/cli.py add-account --name work --description "工作号"
  170 +# 输出: {"success": true, "name": "work", "port": 9223, "profile_dir": "..."}
  171 +
  172 +python scripts/cli.py add-account --name personal
  173 +# 输出: {"success": true, "name": "personal", "port": 9224, "profile_dir": "..."}
  174 +```
  175 +
  176 +### 使用指定账号执行操作
  177 +
  178 +通过全局 `--account` 参数指定账号,CLI 自动切换到对应端口和 Chrome Profile:
  179 +
  180 +```bash
  181 +python scripts/cli.py --account work check-login
  182 +python scripts/cli.py --account work get-qrcode
  183 +python scripts/cli.py --account personal check-login
  184 +python scripts/cli.py check-login # 不指定账号,使用默认端口 9222
  185 +```
  186 +
  187 +### 管理账号
  188 +
  189 +```bash
  190 +python scripts/cli.py list-accounts # 列出所有账号及端口
  191 +python scripts/cli.py set-default-account --name work # 设置默认账号
  192 +python scripts/cli.py remove-account --name personal # 删除账号
  193 +```
  194 +
  195 +---
  196 +
135 ## 失败处理 197 ## 失败处理
136 198
137 - **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。 199 - **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。
@@ -46,6 +46,23 @@ metadata: @@ -46,6 +46,23 @@ metadata:
46 46
47 --- 47 ---
48 48
  49 +## 账号选择(前置步骤)
  50 +
  51 +每次 skill 触发后,先运行:
  52 +
  53 +```bash
  54 +python scripts/cli.py list-accounts
  55 +```
  56 +
  57 +根据返回的 `count`
  58 +- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
  59 +- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
  60 +- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
  61 +
  62 +账号选定后,本次操作全程固定该账号,**不重复询问**
  63 +
  64 +---
  65 +
49 ## 输入判断 66 ## 输入判断
50 67
51 按优先级判断: 68 按优先级判断:
@@ -40,6 +40,23 @@ metadata: @@ -40,6 +40,23 @@ metadata:
40 40
41 --- 41 ---
42 42
  43 +## 账号选择(前置步骤)
  44 +
  45 +每次 skill 触发后,先运行:
  46 +
  47 +```bash
  48 +python scripts/cli.py list-accounts
  49 +```
  50 +
  51 +根据返回的 `count`
  52 +- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
  53 +- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
  54 +- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
  55 +
  56 +账号选定后,本次操作全程固定该账号,**不重复询问**
  57 +
  58 +---
  59 +
43 ## 输入判断 60 ## 输入判断
44 61
45 按优先级判断: 62 按优先级判断:
@@ -40,6 +40,23 @@ metadata: @@ -40,6 +40,23 @@ metadata:
40 40
41 --- 41 ---
42 42
  43 +## 账号选择(前置步骤)
  44 +
  45 +每次 skill 触发后,先运行:
  46 +
  47 +```bash
  48 +python scripts/cli.py list-accounts
  49 +```
  50 +
  51 +根据返回的 `count`
  52 +- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
  53 +- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
  54 +- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
  55 +
  56 +账号选定后,本次操作全程固定该账号,**不重复询问**
  57 +
  58 +---
  59 +
43 ## 输入判断 60 ## 输入判断
44 61
45 按优先级判断: 62 按优先级判断:
@@ -45,6 +45,23 @@ metadata: @@ -45,6 +45,23 @@ metadata:
45 45
46 --- 46 ---
47 47
  48 +## 账号选择(前置步骤)
  49 +
  50 +每次 skill 触发后,先运行:
  51 +
  52 +```bash
  53 +python scripts/cli.py list-accounts
  54 +```
  55 +
  56 +根据返回的 `count`
  57 +- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
  58 +- **1 个命名账号**:告知用户"将使用账号 X 发布",直接加 `--account <名称>` 执行。
  59 +- **多个命名账号**:向用户展示列表,**明确询问发布到哪个账号**,用 `--account <选择的名称>` 执行所有后续命令。
  60 +
  61 +账号选定后,本次发布全程固定该账号,**不重复询问**
  62 +
  63 +---
  64 +
48 ## 输入判断 65 ## 输入判断
49 66
50 按优先级判断: 67 按优先级判断:
  1 +"""account_manager 单元测试。"""
  2 +from __future__ import annotations
  3 +
  4 +import sys
  5 +from pathlib import Path
  6 +
  7 +import pytest
  8 +
  9 +# 把 scripts/ 加入路径,使 account_manager 可导入
  10 +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
  11 +import account_manager
  12 +
  13 +
  14 +@pytest.fixture(autouse=True)
  15 +def tmp_config(tmp_path, monkeypatch):
  16 + """将配置目录重定向到临时目录。"""
  17 + monkeypatch.setattr(account_manager, "_CONFIG_DIR", tmp_path / ".xhs")
  18 + monkeypatch.setattr(
  19 + account_manager, "_ACCOUNTS_FILE", tmp_path / ".xhs" / "accounts.json"
  20 + )
  21 +
  22 +
  23 +def test_add_account_assigns_port():
  24 + """首个命名账号应分配端口 9223。"""
  25 + account_manager.add_account("work", "工作号")
  26 + port = account_manager.get_account_port("work")
  27 + assert port == 9223
  28 +
  29 +
  30 +def test_second_account_gets_next_port():
  31 + """第二个账号应分配端口 9224。"""
  32 + account_manager.add_account("work")
  33 + account_manager.add_account("personal")
  34 + assert account_manager.get_account_port("personal") == 9224
  35 +
  36 +
  37 +def test_get_profile_dir_public():
  38 + """get_profile_dir 应返回正确路径。"""
  39 + account_manager.add_account("work")
  40 + profile = account_manager.get_profile_dir("work")
  41 + assert "work" in profile
  42 + assert "chrome-profile" in profile
  43 +
  44 +
  45 +def test_get_account_port_unknown_raises():
  46 + """不存在的账号应抛出 ValueError。"""
  47 + with pytest.raises(ValueError, match="不存在"):
  48 + account_manager.get_account_port("ghost")
  49 +
  50 +
  51 +def test_list_accounts_includes_port():
  52 + """list_accounts 返回结果中应包含 port 字段。"""
  53 + account_manager.add_account("work", "工作")
  54 + accounts = account_manager.list_accounts()
  55 + assert len(accounts) == 1
  56 + assert accounts[0]["port"] == 9223