Committed by
GitHub
Merge pull request #20 from autoclaw-cc/feat/multi-account-port-isolation
- 增加多账号功能,使用不同的端口对应不同的账号 - 多账号操作:执行任务前,和用户确认在什么账号上操作 - 修复手机验证码体验:使用手机验证码登录时,流程过长,会导致验证码失败
Showing
12 changed files
with
475 additions
and
62 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, |
| @@ -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 | 按优先级判断: |
tests/__init__.py
0 → 100644
tests/test_account_manager.py
0 → 100644
| 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 |
-
Please register or login to post a comment