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:加入登录方式决策树和分步手机登录流程
Showing
8 changed files
with
322 additions
and
61 deletions
| @@ -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 | ||
| 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() | ||
| 140 | 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`。 |
-
Please register or login to post a comment