Angiin

feat: 兼容非 GUI 设备的手机号登录,修复退出登录不彻底问题

- check-login 未登录时返回 login_method(qrcode/phone)和 hint,
  Claude 可根据当前环境自动选择登录方式
- 新增 has_display() 环境检测:Windows/macOS 返回 true,
  Linux 检查 DISPLAY/WAYLAND_DISPLAY 环境变量
- 新增 send-code / verify-code 分步手机登录命令,适用于无界面服务器
- send-code 在频率限制时自动重启 Chrome 并重试一次(RateLimitError)
- delete-cookies 改为通过页面 UI 点击「更多→退出登录」后再删文件,
  修复原先只删 cookies.json 但 Chrome Session 仍保留导致登录状态残留的问题
- 新增 logout() / send_phone_code() / submit_phone_code() 到 login.py
- 新增 LOGOUT_MORE_BUTTON / LOGOUT_MENU_ITEM 选择器
- 新增 RateLimitError 异常类
- 更新 skills/xhs-auth/SKILL.md:加入登录方式决策树和分步手机登录流程
@@ -132,6 +132,7 @@ scripts/cli.py 的 19 个子命令: @@ -132,6 +132,7 @@ scripts/cli.py 的 19 个子命令:
132 |--|--|--| 132 |--|--|--|
133 | `check-login` | check_login_status | 认证 | 133 | `check-login` | check_login_status | 认证 |
134 | `login` | get_login_qrcode | 认证 | 134 | `login` | get_login_qrcode | 认证 |
  135 +| `phone-login` | — | 认证(手机号+验证码,无界面服务器适用) |
135 | `delete-cookies` | delete_cookies | 认证 | 136 | `delete-cookies` | delete_cookies | 认证 |
136 | `list-feeds` | list_feeds | 浏览 | 137 | `list-feeds` | list_feeds | 浏览 |
137 | `search-feeds` | search_feeds | 浏览 | 138 | `search-feeds` | search_feeds | 浏览 |
@@ -35,8 +35,10 @@ description: | @@ -35,8 +35,10 @@ description: |
35 35
36 | 命令 | 功能 | 36 | 命令 | 功能 |
37 |------|------| 37 |------|------|
38 -| `cli.py check-login` | 检查登录状态 |  
39 -| `cli.py login` | 获取登录二维码,等待扫码 | 38 +| `cli.py check-login` | 检查登录状态,返回推荐登录方式 |
  39 +| `cli.py login` | 二维码登录(有界面环境) |
  40 +| `cli.py send-code --phone <号码>` | 手机登录第一步:发送验证码 |
  41 +| `cli.py verify-code --code <验证码>` | 手机登录第二步:提交验证码 |
40 | `cli.py delete-cookies` | 清除 cookies(退出/切换账号) | 42 | `cli.py delete-cookies` | 清除 cookies(退出/切换账号) |
41 43
42 ### xhs-publish — 内容发布 44 ### xhs-publish — 内容发布
@@ -376,3 +376,12 @@ def _mask_proxy(proxy_url: str) -> str: @@ -376,3 +376,12 @@ def _mask_proxy(proxy_url: str) -> str:
376 except Exception: 376 except Exception:
377 pass 377 pass
378 return proxy_url 378 return proxy_url
  379 +
  380 +
  381 +def has_display() -> bool:
  382 + """检测当前环境是否有图形界面(用于自动选择登录方式)。"""
  383 + system = platform.system()
  384 + if system in ("Windows", "Darwin"):
  385 + return True # Windows / macOS 默认有 GUI
  386 + # Linux: 检查 DISPLAY 或 WAYLAND_DISPLAY 环境变量
  387 + return bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
@@ -97,7 +97,17 @@ def cmd_check_login(args: argparse.Namespace) -> None: @@ -97,7 +97,17 @@ def cmd_check_login(args: argparse.Namespace) -> None:
97 browser, page = _connect(args) 97 browser, page = _connect(args)
98 try: 98 try:
99 logged_in = check_login_status(page) 99 logged_in = check_login_status(page)
100 - _output({"logged_in": logged_in}, exit_code=0 if logged_in else 1) 100 + if logged_in:
  101 + _output({"logged_in": True}, exit_code=0)
  102 + else:
  103 + from chrome_launcher import has_display
  104 + method = "qrcode" if has_display() else "phone"
  105 + hint = (
  106 + "请运行 login(二维码)完成登录"
  107 + if method == "qrcode"
  108 + else "请运行 send-code --phone <手机号>(手机验证码)完成登录"
  109 + )
  110 + _output({"logged_in": False, "login_method": method, "hint": hint}, exit_code=1)
101 finally: 111 finally:
102 browser.close_page(page) 112 browser.close_page(page)
103 browser.close() 113 browser.close()
@@ -134,13 +144,116 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -134,13 +144,116 @@ def cmd_login(args: argparse.Namespace) -> None:
134 browser.close() 144 browser.close()
135 145
136 146
  147 +def cmd_phone_login(args: argparse.Namespace) -> None:
  148 + """手机号+验证码登录(适用于无界面服务器)。"""
  149 + from xhs.login import send_phone_code, submit_phone_code
  150 +
  151 + browser, page = _connect(args)
  152 + try:
  153 + sent = send_phone_code(page, args.phone)
  154 + if not sent:
  155 + _output({"logged_in": True, "message": "已登录,无需重新登录"})
  156 + return
  157 +
  158 + # 输出提示,等待用户在终端输入验证码
  159 + print(
  160 + json.dumps(
  161 + {"status": "code_sent", "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]}"},
  162 + ensure_ascii=False,
  163 + ),
  164 + flush=True,
  165 + )
  166 +
  167 + # 从 --code 参数或交互式 stdin 读取验证码
  168 + if args.code:
  169 + code = args.code.strip()
  170 + else:
  171 + try:
  172 + code = input("请输入验证码: ").strip()
  173 + except EOFError:
  174 + _output({"success": False, "error": "未收到验证码输入"}, exit_code=2)
  175 + return
  176 +
  177 + if not code:
  178 + _output({"success": False, "error": "验证码不能为空"}, exit_code=2)
  179 + return
  180 +
  181 + success = submit_phone_code(page, code)
  182 + _output(
  183 + {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
  184 + exit_code=0 if success else 2,
  185 + )
  186 + finally:
  187 + browser.close_page(page)
  188 + browser.close()
  189 +
  190 +
  191 +def cmd_send_code(args: argparse.Namespace) -> None:
  192 + """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。"""
  193 + from chrome_launcher import restart_chrome
  194 + from xhs.errors import RateLimitError
  195 + from xhs.login import send_phone_code
  196 +
  197 + for attempt in range(2):
  198 + browser, page = _connect(args)
  199 + try:
  200 + sent = send_phone_code(page, args.phone)
  201 + if not sent:
  202 + _output({"logged_in": True, "message": "已登录,无需重新登录"})
  203 + return
  204 +
  205 + _output({
  206 + "status": "code_sent",
  207 + "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>",
  208 + })
  209 + except RateLimitError:
  210 + browser.close()
  211 + if attempt == 0:
  212 + logger.info("请求频率限制,重启 Chrome 后重试...")
  213 + restart_chrome(port=args.port)
  214 + continue
  215 + _output({"success": False, "error": "请求太频繁,重启后仍失败,请稍后再试"}, exit_code=2)
  216 + else:
  217 + # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用
  218 + browser.close()
  219 + return
  220 +
  221 +
  222 +def cmd_verify_code(args: argparse.Namespace) -> None:
  223 + """分步登录第二步:在已有页面上填写验证码并提交。"""
  224 + from xhs.login import submit_phone_code
  225 +
  226 + browser, page = _connect_existing(args)
  227 + try:
  228 + success = submit_phone_code(page, args.code)
  229 + _output(
  230 + {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
  231 + exit_code=0 if success else 2,
  232 + )
  233 + finally:
  234 + browser.close_page(page)
  235 + browser.close()
  236 +
  237 +
137 def cmd_delete_cookies(args: argparse.Namespace) -> None: 238 def cmd_delete_cookies(args: argparse.Namespace) -> None:
138 - """删除 cookies。""" 239 + """退出登录(页面 UI 点击退出)并删除 cookies 文件。"""
139 from xhs.cookies import delete_cookies, get_cookies_file_path 240 from xhs.cookies import delete_cookies, get_cookies_file_path
  241 + from xhs.login import logout
140 242
  243 + # 先通过浏览器 UI 退出登录
  244 + browser, page = _connect(args)
  245 + try:
  246 + logged_out = logout(page)
  247 + finally:
  248 + browser.close_page(page)
  249 + browser.close()
  250 +
  251 + # 再删除本地 cookies 文件
141 path = get_cookies_file_path(args.account) 252 path = get_cookies_file_path(args.account)
142 delete_cookies(path) 253 delete_cookies(path)
143 - _output({"success": True, "message": f"已删除 cookies: {path}"}) 254 +
  255 + msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
  256 + _output({"success": True, "message": msg, "cookies_path": path})
144 257
145 258
146 def cmd_list_feeds(args: argparse.Namespace) -> None: 259 def cmd_list_feeds(args: argparse.Namespace) -> None:
@@ -560,6 +673,22 @@ def build_parser() -> argparse.ArgumentParser: @@ -560,6 +673,22 @@ def build_parser() -> argparse.ArgumentParser:
560 sub = subparsers.add_parser("login", help="登录(扫码)") 673 sub = subparsers.add_parser("login", help="登录(扫码)")
561 sub.set_defaults(func=cmd_login) 674 sub.set_defaults(func=cmd_login)
562 675
  676 + # phone-login(单命令交互式)
  677 + sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)")
  678 + sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)")
  679 + sub.add_argument("--code", default="", help="短信验证码(省略则交互式输入)")
  680 + sub.set_defaults(func=cmd_phone_login)
  681 +
  682 + # send-code(分步登录第一步)
  683 + sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码,保持页面不关闭")
  684 + sub.add_argument("--phone", required=True, help="手机号(不含国家码)")
  685 + sub.set_defaults(func=cmd_send_code)
  686 +
  687 + # verify-code(分步登录第二步)
  688 + sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码并完成登录")
  689 + sub.add_argument("--code", required=True, help="收到的短信验证码")
  690 + sub.set_defaults(func=cmd_verify_code)
  691 +
563 # delete-cookies 692 # delete-cookies
564 sub = subparsers.add_parser("delete-cookies", help="删除 cookies") 693 sub = subparsers.add_parser("delete-cookies", help="删除 cookies")
565 sub.set_defaults(func=cmd_delete_cookies) 694 sub.set_defaults(func=cmd_delete_cookies)
@@ -60,6 +60,13 @@ class ContentTooLongError(PublishError): @@ -60,6 +60,13 @@ class ContentTooLongError(PublishError):
60 super().__init__(f"当前输入长度为{current},最大长度为{maximum}") 60 super().__init__(f"当前输入长度为{current},最大长度为{maximum}")
61 61
62 62
  63 +class RateLimitError(XHSError):
  64 + """请求频率过高,验证码获取失败。"""
  65 +
  66 + def __init__(self) -> None:
  67 + super().__init__("请求太频繁,验证码获取失败,请重启浏览器后重试")
  68 +
  69 +
63 class CDPError(XHSError): 70 class CDPError(XHSError):
64 """CDP 通信异常。""" 71 """CDP 通信异常。"""
65 72
@@ -9,8 +9,22 @@ import tempfile @@ -9,8 +9,22 @@ import tempfile
9 import time 9 import time
10 10
11 from .cdp import Page 11 from .cdp import Page
  12 +from .errors import RateLimitError
12 from .human import sleep_random 13 from .human import sleep_random
13 -from .selectors import LOGIN_STATUS, QRCODE_IMG 14 +from .selectors import (
  15 + AGREE_CHECKBOX,
  16 + AGREE_CHECKBOX_CHECKED,
  17 + CODE_INPUT,
  18 + GET_CODE_BUTTON,
  19 + LOGIN_CONTAINER,
  20 + LOGIN_ERR_MSG,
  21 + LOGIN_STATUS,
  22 + LOGOUT_MENU_ITEM,
  23 + LOGOUT_MORE_BUTTON,
  24 + PHONE_INPUT,
  25 + PHONE_LOGIN_SUBMIT,
  26 + QRCODE_IMG,
  27 +)
14 from .urls import EXPLORE_URL 28 from .urls import EXPLORE_URL
15 29
16 logger = logging.getLogger(__name__) 30 logger = logging.getLogger(__name__)
@@ -84,6 +98,115 @@ def save_qrcode_to_file(src: str) -> str: @@ -84,6 +98,115 @@ def save_qrcode_to_file(src: str) -> str:
84 return filepath 98 return filepath
85 99
86 100
  101 +def send_phone_code(page: Page, phone: str) -> bool:
  102 + """填写手机号并发送短信验证码。
  103 +
  104 + 适用于无界面服务器场景,全程通过 CDP 操作,无需扫码。
  105 +
  106 + Args:
  107 + page: CDP 页面对象。
  108 + phone: 手机号(不含国家码,如 13800138000)。
  109 +
  110 + Returns:
  111 + True 验证码已发送,False 已登录(无需再登录)。
  112 +
  113 + Raises:
  114 + RuntimeError: 找不到登录表单或手机号输入框。
  115 + """
  116 + page.navigate(EXPLORE_URL)
  117 + page.wait_for_load()
  118 + sleep_random(1500, 2500)
  119 +
  120 + if page.has_element(LOGIN_STATUS):
  121 + return False
  122 +
  123 + # 等待登录弹窗出现
  124 + page.wait_for_element(LOGIN_CONTAINER, timeout=15.0)
  125 + sleep_random(500, 800)
  126 +
  127 + # 点击手机号输入框并逐字输入
  128 + page.click_element(PHONE_INPUT)
  129 + sleep_random(200, 400)
  130 + page.type_text(phone, delay_ms=80)
  131 + sleep_random(500, 800)
  132 +
  133 + # 先勾选用户协议,再点获取验证码
  134 + if not page.has_element(AGREE_CHECKBOX_CHECKED):
  135 + page.click_element(AGREE_CHECKBOX)
  136 + sleep_random(300, 600)
  137 +
  138 + # 点击"获取验证码"
  139 + page.click_element(GET_CODE_BUTTON)
  140 + sleep_random(2000, 2500)
  141 +
  142 + # 检测按钮是否变为倒计时(成功发送后按钮文字会包含数字秒数)
  143 + btn_text = page.get_element_text(GET_CODE_BUTTON) or ""
  144 + if not any(ch.isdigit() for ch in btn_text):
  145 + raise RateLimitError()
  146 +
  147 + logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:])
  148 + return True
  149 +
  150 +
  151 +def submit_phone_code(page: Page, code: str) -> bool:
  152 + """填写短信验证码并提交登录。
  153 +
  154 + Args:
  155 + page: CDP 页面对象。
  156 + code: 收到的短信验证码。
  157 +
  158 + Returns:
  159 + True 登录成功,False 失败(超时或验证码错误)。
  160 + """
  161 + # 点击验证码输入框并逐字输入
  162 + page.click_element(CODE_INPUT)
  163 + sleep_random(300, 500)
  164 + page.type_text(code, delay_ms=100)
  165 + sleep_random(500, 800)
  166 +
  167 + # 点击登录按钮
  168 + page.click_element(PHONE_LOGIN_SUBMIT)
  169 + sleep_random(1000, 2000)
  170 +
  171 + # 检查是否有错误提示
  172 + err = page.get_element_text(LOGIN_ERR_MSG)
  173 + if err and err.strip():
  174 + logger.warning("登录失败: %s", err.strip())
  175 + return False
  176 +
  177 + return wait_for_login(page, timeout=30.0)
  178 +
  179 +
  180 +def logout(page: Page) -> bool:
  181 + """通过页面 UI 退出登录(点击"更多"→"退出登录")。
  182 +
  183 + Args:
  184 + page: CDP 页面对象。
  185 +
  186 + Returns:
  187 + True 退出成功,False 未登录或操作失败。
  188 + """
  189 + page.navigate(EXPLORE_URL)
  190 + page.wait_for_load()
  191 + sleep_random(800, 1500)
  192 +
  193 + if not page.has_element(LOGIN_STATUS):
  194 + logger.info("当前未登录,无需退出")
  195 + return False
  196 +
  197 + # 点击"更多"按钮展开菜单
  198 + page.click_element(LOGOUT_MORE_BUTTON)
  199 + sleep_random(500, 800)
  200 +
  201 + # 等待退出菜单项出现并点击
  202 + page.wait_for_element(LOGOUT_MENU_ITEM, timeout=5.0)
  203 + page.click_element(LOGOUT_MENU_ITEM)
  204 + sleep_random(1000, 1500)
  205 +
  206 + logger.info("已退出登录")
  207 + return True
  208 +
  209 +
87 def wait_for_login(page: Page, timeout: float = 120.0) -> bool: 210 def wait_for_login(page: Page, timeout: float = 120.0) -> bool:
88 """等待扫码登录完成。 211 """等待扫码登录完成。
89 212
@@ -4,6 +4,16 @@ @@ -4,6 +4,16 @@
4 LOGIN_STATUS = ".main-container .user .link-wrapper .channel" 4 LOGIN_STATUS = ".main-container .user .link-wrapper .channel"
5 QRCODE_IMG = ".login-container .qrcode-img" 5 QRCODE_IMG = ".login-container .qrcode-img"
6 6
  7 +# ========== 手机号登录 ==========
  8 +LOGIN_CONTAINER = ".login-container"
  9 +PHONE_INPUT = "label.phone input"
  10 +GET_CODE_BUTTON = "span.code-button"
  11 +CODE_INPUT = "label.auth-code input"
  12 +PHONE_LOGIN_SUBMIT = ".input-container button.submit"
  13 +AGREE_CHECKBOX = ".agree-icon .icon-wrapper"
  14 +AGREE_CHECKBOX_CHECKED = ".agree-icon .icon-wrapper.agreed"
  15 +LOGIN_ERR_MSG = ".err-msg"
  16 +
7 # ========== 首页 / 搜索 ========== 17 # ========== 首页 / 搜索 ==========
8 FILTER_BUTTON = "div.filter" 18 FILTER_BUTTON = "div.filter"
9 FILTER_PANEL = "div.filter-panel" 19 FILTER_PANEL = "div.filter-panel"
@@ -75,5 +85,9 @@ LONG_ARTICLE_TITLE = 'textarea.d-text[placeholder="输入标题"]' @@ -75,5 +85,9 @@ LONG_ARTICLE_TITLE = 'textarea.d-text[placeholder="输入标题"]'
75 TEMPLATE_CARD = ".template-card" 85 TEMPLATE_CARD = ".template-card"
76 TEMPLATE_TITLE = ".template-card .template-title" 86 TEMPLATE_TITLE = ".template-card .template-title"
77 87
  88 +# ========== 退出登录 ==========
  89 +LOGOUT_MORE_BUTTON = "div.information-wrapper"
  90 +LOGOUT_MENU_ITEM = 'div.menu-item[data-name="退出登录"]'
  91 +
78 # ========== 用户主页 ========== 92 # ========== 用户主页 ==========
79 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"
1 --- 1 ---
2 name: xhs-auth 2 name: xhs-auth
3 description: | 3 description: |
4 - 小红书认证管理技能。检查登录状态、扫码登录、多账号管理。 4 + 小红书认证管理技能。检查登录状态、登录(二维码或手机号)、多账号管理。
5 当用户要求登录小红书、检查登录状态、切换账号时触发。 5 当用户要求登录小红书、检查登录状态、切换账号时触发。
6 --- 6 ---
7 7
@@ -14,92 +14,68 @@ description: | @@ -14,92 +14,68 @@ description: |
14 按优先级判断用户意图: 14 按优先级判断用户意图:
15 15
16 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。 16 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。
17 -2. 用户要求"登录 / 扫码登录 / 打开登录页":执行登录流程。 17 +2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。
18 3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 cookie 清除。 18 3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 cookie 清除。
19 19
20 ## 必做约束 20 ## 必做约束
21 21
22 -- 登录操作需要用户手动扫码,不可自动化完成。  
23 - 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。 22 - 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。
24 -- 需要先有运行中的 Chrome(通过 `scripts/chrome_launcher.py` 启动)。 23 +- 需要先有运行中的 Chrome(`ensure_chrome` 会自动启动)。
25 - 如果使用文件路径,必须使用绝对路径。 24 - 如果使用文件路径,必须使用绝对路径。
26 25
27 ## 工作流程 26 ## 工作流程
28 27
29 -### 检查登录状态 28 +### 第一步:检查登录状态
30 29
31 ```bash 30 ```bash
32 -# 默认连接本地 Chrome  
33 python scripts/cli.py check-login 31 python scripts/cli.py check-login
34 -  
35 -# 指定端口  
36 -python scripts/cli.py --port 9222 check-login  
37 -  
38 -# 连接远程 Chrome  
39 -python scripts/cli.py --host 10.0.0.12 --port 9222 check-login  
40 ``` 32 ```
41 33
42 输出解读: 34 输出解读:
43 -- `"logged_in": true` + exit code 0 → 已登录,可执行后续操作。  
44 -- `"logged_in": false` + exit code 1 → 未登录,提示用户扫码。 35 +- `"logged_in": true` → 已登录,可执行后续操作。
  36 +- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,使用二维码登录。
  37 +- `"logged_in": false` + `"login_method": "phone"` → 无界面服务器,使用手机验证码登录。
45 38
46 -### 登录流程 39 +### 第二步:根据 login_method 选择登录方式
47 40
48 -1. 确保 Chrome 已启动(有窗口模式,便于扫码):  
49 -```bash  
50 -python scripts/chrome_launcher.py  
51 -``` 41 +#### 方式 A:二维码登录(有界面环境)
52 42
53 -2. 获取登录二维码并等待扫码:  
54 ```bash 43 ```bash
55 python scripts/cli.py login 44 python scripts/cli.py login
56 ``` 45 ```
57 46
58 -3. 脚本首先输出一行 JSON,包含 `qrcode_path` 字段(二维码图片保存路径),然后阻塞等待扫码。 47 +1. 命令立即输出 `qrcode_path`(二维码图片路径),然后阻塞等待扫码(最多 120 秒)。
  48 +2. 提示用户用小红书 App 或微信扫码。
  49 +3. 扫码成功后输出 `"logged_in": true`
59 50
60 -4. **展示二维码给用户**:从输出中提取 `qrcode_path`,用系统命令打开图片供用户扫码:  
61 -```bash  
62 -# macOS  
63 -open /tmp/xhs/login_qrcode.png 51 +#### 方式 B:手机验证码登录(无界面服务器,分两步)
64 52
65 -# Linux  
66 -xdg-open /tmp/xhs/login_qrcode.png 53 +**第一步** — 询问用户手机号后发送验证码:
  54 +```bash
  55 +python scripts/cli.py send-code --phone <手机号>
67 ``` 56 ```
68 -告知用户:"请用小红书 App 扫描二维码登录"。  
69 -  
70 -5. 用户扫码成功后,脚本自动检测并输出第二行 JSON:`"logged_in": true`  
71 -  
72 -**注意**`login` 命令会阻塞最多 120 秒等待扫码。由于命令阻塞期间无法执行其他操作,应提前在另一个终端或通过后台方式打开图片。推荐流程是先运行 `login` 命令(它会立即输出二维码路径),然后提示用户自行打开图片文件扫码。  
73 -  
74 -### 清除 Cookies(切换账号/退出登录) 57 +- 自动填写手机号、勾选用户协议、点击"获取验证码"。
  58 +- Chrome 页面保持打开,等待下一步。
  59 +- 输出:`{"status": "code_sent", "message": "验证码已发送至 138****0000,请运行 verify-code --code <验证码>"}`
75 60
  61 +**第二步** — 询问用户收到的验证码后提交:
76 ```bash 62 ```bash
77 -# 清除当前账号 cookies  
78 -python scripts/cli.py delete-cookies  
79 -  
80 -# 指定账号清除  
81 -python scripts/cli.py --account work delete-cookies 63 +python scripts/cli.py verify-code --code <6位验证码>
82 ``` 64 ```
  65 +- 自动填写验证码、点击登录。
  66 +- 输出:`{"logged_in": true, "message": "登录成功"}`
83 67
84 -### 启动 / 关闭浏览器 68 +### 清除 Cookies(切换账号/退出登录)
85 69
86 ```bash 70 ```bash
87 -# 启动 Chrome(有窗口,推荐用于登录)  
88 -python scripts/chrome_launcher.py  
89 -  
90 -# 无头启动  
91 -python scripts/chrome_launcher.py --headless  
92 -  
93 -# 指定端口  
94 -python scripts/chrome_launcher.py --port 9223  
95 -  
96 -# 关闭 Chrome  
97 -python scripts/chrome_launcher.py --kill 71 +python scripts/cli.py delete-cookies
  72 +python scripts/cli.py --account work delete-cookies # 指定账号
98 ``` 73 ```
99 74
100 ## 失败处理 75 ## 失败处理
101 76
102 -- **Chrome 未找到**:提示用户安装 Google Chrome 或设置路径。  
103 -- **端口被占用**:提示使用 `--port` 指定其他端口,或先执行 `--kill` 关闭现有实例。  
104 -- **扫码超时**:提示用户重新执行登录命令。  
105 -- **远程 CDP 连接失败**:检查远程 Chrome 是否已开启调试端口。 77 +- **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。
  78 +- **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`
  79 +- **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`
  80 +- **二维码超时**:重新执行 `login` 命令。
  81 +- **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`