Angiin

feat: 多账号端口隔离 + 账号选择前置步骤

- account_manager: 每个命名账号自动分配独立端口(9223+),新增
  get_account_port()、公开 get_profile_dir()、update_account_description()
- cli: 新增 _resolve_account(),connect 函数按账号切换端口和 Chrome Profile;
  tab 文件按端口隔离(session_tab_<port>.txt / login_tab_<port>.txt);
  新增 add-account / list-accounts / remove-account / set-default-account 4 个子命令;
  登录成功后自动读取平台昵称写入账号描述
- xhs/login: 新增 get_current_user_nickname(),从首页导航栏读取昵称
- 5 个 SKILL.md: 统一添加账号选择前置步骤(0/1 账号免选,多账号必选)
- CLAUDE.md: CLI 子命令表追加 4 个账号管理命令
@@ -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,
@@ -319,7 +365,7 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -319,7 +365,7 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
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 367 # 记录 tab,供 wait-login 精确reconnect
322 - _save_login_tab(page.target_id) 368 + _save_login_tab(page.target_id, args.port)
323 369
324 # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码 370 # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码
325 browser.close() 371 browser.close()
@@ -340,7 +386,8 @@ def cmd_wait_login(args: argparse.Namespace) -> None: @@ -340,7 +386,8 @@ def cmd_wait_login(args: argparse.Namespace) -> None:
340 try: 386 try:
341 success = wait_for_login(page, timeout=args.timeout) 387 success = wait_for_login(page, timeout=args.timeout)
342 if success: 388 if success:
343 - _clear_login_tab() 389 + _clear_login_tab(args.port)
  390 + _update_account_nickname(args, page)
344 _output( 391 _output(
345 { 392 {
346 "logged_in": success, 393 "logged_in": success,
@@ -367,7 +414,7 @@ def cmd_send_code(args: argparse.Namespace) -> None: @@ -367,7 +414,7 @@ def cmd_send_code(args: argparse.Namespace) -> None:
367 return 414 return
368 415
369 # 记录 tab,供 verify-code 精确 reconnect 416 # 记录 tab,供 verify-code 精确 reconnect
370 - _save_login_tab(page.target_id) 417 + _save_login_tab(page.target_id, args.port)
371 _output({ 418 _output({
372 "status": "code_sent", 419 "status": "code_sent",
373 "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>", 420 "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>",
@@ -393,7 +440,8 @@ def cmd_verify_code(args: argparse.Namespace) -> None: @@ -393,7 +440,8 @@ def cmd_verify_code(args: argparse.Namespace) -> None:
393 try: 440 try:
394 success = submit_phone_code(page, args.code) 441 success = submit_phone_code(page, args.code)
395 if success: 442 if success:
396 - _clear_login_tab() 443 + _clear_login_tab(args.port)
  444 + _update_account_nickname(args, page)
397 _output( 445 _output(
398 {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, 446 {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
399 exit_code=0 if success else 2, 447 exit_code=0 if success else 2,
@@ -420,7 +468,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None: @@ -420,7 +468,7 @@ def cmd_delete_cookies(args: argparse.Namespace) -> None:
420 path = get_cookies_file_path(args.account) 468 path = get_cookies_file_path(args.account)
421 delete_cookies(path) 469 delete_cookies(path)
422 470
423 - _clear_session_tab() # 退出登录后清除会话 tab 记录 471 + _clear_session_tab(args.port) # 退出登录后清除会话 tab 记录
424 msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件" 472 msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
425 _output({"success": True, "message": msg, "cookies_path": path}) 473 _output({"success": True, "message": msg, "cookies_path": path})
426 474
@@ -817,6 +865,55 @@ def cmd_publish_video(args: argparse.Namespace) -> None: @@ -817,6 +865,55 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
817 browser.close() 865 browser.close()
818 866
819 867
  868 +# ========== 账号管理子命令 ==========
  869 +
  870 +
  871 +def cmd_add_account(args: argparse.Namespace) -> None:
  872 + """添加命名账号,自动分配独立端口和 Chrome Profile。"""
  873 + import sys as _sys
  874 +
  875 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  876 + import account_manager
  877 +
  878 + account_manager.add_account(args.name, description=args.description or "")
  879 + port = account_manager.get_account_port(args.name)
  880 + profile = account_manager.get_profile_dir(args.name)
  881 + _output({"success": True, "name": args.name, "port": port, "profile_dir": profile})
  882 +
  883 +
  884 +def cmd_list_accounts(args: argparse.Namespace) -> None:
  885 + """列出所有命名账号。"""
  886 + import sys as _sys
  887 +
  888 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  889 + import account_manager
  890 +
  891 + accounts = account_manager.list_accounts()
  892 + _output({"accounts": accounts, "count": len(accounts)})
  893 +
  894 +
  895 +def cmd_remove_account(args: argparse.Namespace) -> None:
  896 + """删除命名账号。"""
  897 + import sys as _sys
  898 +
  899 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  900 + import account_manager
  901 +
  902 + account_manager.remove_account(args.name)
  903 + _output({"success": True, "name": args.name})
  904 +
  905 +
  906 +def cmd_set_default_account(args: argparse.Namespace) -> None:
  907 + """设置默认账号。"""
  908 + import sys as _sys
  909 +
  910 + _sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
  911 + import account_manager
  912 +
  913 + account_manager.set_default_account(args.name)
  914 + _output({"success": True, "default": args.name})
  915 +
  916 +
820 # ========== 参数解析 ========== 917 # ========== 参数解析 ==========
821 918
822 919
@@ -1001,6 +1098,26 @@ def build_parser() -> argparse.ArgumentParser: @@ -1001,6 +1098,26 @@ def build_parser() -> argparse.ArgumentParser:
1001 sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)") 1098 sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)")
1002 sub.set_defaults(func=cmd_save_draft) 1099 sub.set_defaults(func=cmd_save_draft)
1003 1100
  1101 + # add-account(添加命名账号)
  1102 + sub = subparsers.add_parser("add-account", help="添加命名账号,自动分配独立端口")
  1103 + sub.add_argument("--name", required=True, help="账号名称")
  1104 + sub.add_argument("--description", default="", help="账号描述(可选)")
  1105 + sub.set_defaults(func=cmd_add_account)
  1106 +
  1107 + # list-accounts(列出所有账号)
  1108 + sub = subparsers.add_parser("list-accounts", help="列出所有命名账号")
  1109 + sub.set_defaults(func=cmd_list_accounts)
  1110 +
  1111 + # remove-account(删除账号)
  1112 + sub = subparsers.add_parser("remove-account", help="删除命名账号")
  1113 + sub.add_argument("--name", required=True, help="账号名称")
  1114 + sub.set_defaults(func=cmd_remove_account)
  1115 +
  1116 + # set-default-account(设置默认账号)
  1117 + sub = subparsers.add_parser("set-default-account", help="设置默认账号")
  1118 + sub.add_argument("--name", required=True, help="账号名称")
  1119 + sub.set_defaults(func=cmd_set_default_account)
  1120 +
1004 return parser 1121 return parser
1005 1122
1006 1123
@@ -47,6 +47,23 @@ def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None: @@ -47,6 +47,23 @@ def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:
47 time.sleep(0.2) 47 time.sleep(0.2)
48 48
49 49
  50 +def get_current_user_nickname(page: Page) -> str:
  51 + """获取当前登录用户的昵称,失败时返回空字符串(best-effort)。"""
  52 + try:
  53 + page.navigate(EXPLORE_URL)
  54 + page.wait_for_load()
  55 + _wait_for_auth_ui(page)
  56 + if not page.has_element(LOGIN_STATUS):
  57 + return ""
  58 + nickname = page.evaluate(
  59 + f"document.querySelector({json.dumps(LOGIN_STATUS)})?.innerText?.trim() || ''"
  60 + )
  61 + return nickname or ""
  62 + except Exception:
  63 + logger.warning("获取用户昵称失败")
  64 + return ""
  65 +
  66 +
50 def check_login_status(page: Page) -> bool: 67 def check_login_status(page: Page) -> bool:
51 """检查登录状态。 68 """检查登录状态。
52 69
@@ -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
@@ -132,6 +155,41 @@ python scripts/cli.py delete-cookies @@ -132,6 +155,41 @@ python scripts/cli.py delete-cookies
132 python scripts/cli.py --account work delete-cookies # 指定账号 155 python scripts/cli.py --account work delete-cookies # 指定账号
133 ``` 156 ```
134 157
  158 +## 多账号工作流
  159 +
  160 +每个命名账号拥有独立端口(从 9223 起递增)和独立 Chrome Profile,账号之间完全隔离。
  161 +
  162 +### 添加账号
  163 +
  164 +```bash
  165 +python scripts/cli.py add-account --name work --description "工作号"
  166 +# 输出: {"success": true, "name": "work", "port": 9223, "profile_dir": "..."}
  167 +
  168 +python scripts/cli.py add-account --name personal
  169 +# 输出: {"success": true, "name": "personal", "port": 9224, "profile_dir": "..."}
  170 +```
  171 +
  172 +### 使用指定账号执行操作
  173 +
  174 +通过全局 `--account` 参数指定账号,CLI 自动切换到对应端口和 Chrome Profile:
  175 +
  176 +```bash
  177 +python scripts/cli.py --account work check-login
  178 +python scripts/cli.py --account work get-qrcode
  179 +python scripts/cli.py --account personal check-login
  180 +python scripts/cli.py check-login # 不指定账号,使用默认端口 9222
  181 +```
  182 +
  183 +### 管理账号
  184 +
  185 +```bash
  186 +python scripts/cli.py list-accounts # 列出所有账号及端口
  187 +python scripts/cli.py set-default-account --name work # 设置默认账号
  188 +python scripts/cli.py remove-account --name personal # 删除账号
  189 +```
  190 +
  191 +---
  192 +
135 ## 失败处理 193 ## 失败处理
136 194
137 - **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。 195 - **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 按优先级判断: