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 个账号管理命令
Showing
9 changed files
with
336 additions
and
39 deletions
| @@ -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 | 按优先级判断: |
-
Please register or login to post a comment