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 个子命令:
|--|--|--|
| `check-login` | check_login_status | 认证 |
| `login` | get_login_qrcode | 认证 |
| `phone-login` | — | 认证(手机号+验证码,无界面服务器适用) |
| `delete-cookies` | delete_cookies | 认证 |
| `list-feeds` | list_feeds | 浏览 |
| `search-feeds` | search_feeds | 浏览 |
... ...
... ... @@ -35,8 +35,10 @@ description: |
| 命令 | 功能 |
|------|------|
| `cli.py check-login` | 检查登录状态 |
| `cli.py login` | 获取登录二维码,等待扫码 |
| `cli.py check-login` | 检查登录状态,返回推荐登录方式 |
| `cli.py login` | 二维码登录(有界面环境) |
| `cli.py send-code --phone <号码>` | 手机登录第一步:发送验证码 |
| `cli.py verify-code --code <验证码>` | 手机登录第二步:提交验证码 |
| `cli.py delete-cookies` | 清除 cookies(退出/切换账号) |
### xhs-publish — 内容发布
... ...
... ... @@ -376,3 +376,12 @@ def _mask_proxy(proxy_url: str) -> str:
except Exception:
pass
return proxy_url
def has_display() -> bool:
"""检测当前环境是否有图形界面(用于自动选择登录方式)。"""
system = platform.system()
if system in ("Windows", "Darwin"):
return True # Windows / macOS 默认有 GUI
# Linux: 检查 DISPLAY 或 WAYLAND_DISPLAY 环境变量
return bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
... ...
... ... @@ -97,7 +97,17 @@ def cmd_check_login(args: argparse.Namespace) -> None:
browser, page = _connect(args)
try:
logged_in = check_login_status(page)
_output({"logged_in": logged_in}, exit_code=0 if logged_in else 1)
if logged_in:
_output({"logged_in": True}, exit_code=0)
else:
from chrome_launcher import has_display
method = "qrcode" if has_display() else "phone"
hint = (
"请运行 login(二维码)完成登录"
if method == "qrcode"
else "请运行 send-code --phone <手机号>(手机验证码)完成登录"
)
_output({"logged_in": False, "login_method": method, "hint": hint}, exit_code=1)
finally:
browser.close_page(page)
browser.close()
... ... @@ -134,13 +144,116 @@ def cmd_login(args: argparse.Namespace) -> None:
browser.close()
def cmd_phone_login(args: argparse.Namespace) -> None:
"""手机号+验证码登录(适用于无界面服务器)。"""
from xhs.login import send_phone_code, submit_phone_code
browser, page = _connect(args)
try:
sent = send_phone_code(page, args.phone)
if not sent:
_output({"logged_in": True, "message": "已登录,无需重新登录"})
return
# 输出提示,等待用户在终端输入验证码
print(
json.dumps(
{"status": "code_sent", "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]}"},
ensure_ascii=False,
),
flush=True,
)
# 从 --code 参数或交互式 stdin 读取验证码
if args.code:
code = args.code.strip()
else:
try:
code = input("请输入验证码: ").strip()
except EOFError:
_output({"success": False, "error": "未收到验证码输入"}, exit_code=2)
return
if not code:
_output({"success": False, "error": "验证码不能为空"}, exit_code=2)
return
success = submit_phone_code(page, code)
_output(
{"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
exit_code=0 if success else 2,
)
finally:
browser.close_page(page)
browser.close()
def cmd_send_code(args: argparse.Namespace) -> None:
"""分步登录第一步:填写手机号并发送验证码,保持页面不关闭。"""
from chrome_launcher import restart_chrome
from xhs.errors import RateLimitError
from xhs.login import send_phone_code
for attempt in range(2):
browser, page = _connect(args)
try:
sent = send_phone_code(page, args.phone)
if not sent:
_output({"logged_in": True, "message": "已登录,无需重新登录"})
return
_output({
"status": "code_sent",
"message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>",
})
except RateLimitError:
browser.close()
if attempt == 0:
logger.info("请求频率限制,重启 Chrome 后重试...")
restart_chrome(port=args.port)
continue
_output({"success": False, "error": "请求太频繁,重启后仍失败,请稍后再试"}, exit_code=2)
else:
# 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用
browser.close()
return
def cmd_verify_code(args: argparse.Namespace) -> None:
"""分步登录第二步:在已有页面上填写验证码并提交。"""
from xhs.login import submit_phone_code
browser, page = _connect_existing(args)
try:
success = submit_phone_code(page, args.code)
_output(
{"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
exit_code=0 if success else 2,
)
finally:
browser.close_page(page)
browser.close()
def cmd_delete_cookies(args: argparse.Namespace) -> None:
"""删除 cookies。"""
"""退出登录(页面 UI 点击退出)并删除 cookies 文件。"""
from xhs.cookies import delete_cookies, get_cookies_file_path
from xhs.login import logout
# 先通过浏览器 UI 退出登录
browser, page = _connect(args)
try:
logged_out = logout(page)
finally:
browser.close_page(page)
browser.close()
# 再删除本地 cookies 文件
path = get_cookies_file_path(args.account)
delete_cookies(path)
_output({"success": True, "message": f"已删除 cookies: {path}"})
msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
_output({"success": True, "message": msg, "cookies_path": path})
def cmd_list_feeds(args: argparse.Namespace) -> None:
... ... @@ -560,6 +673,22 @@ def build_parser() -> argparse.ArgumentParser:
sub = subparsers.add_parser("login", help="登录(扫码)")
sub.set_defaults(func=cmd_login)
# phone-login(单命令交互式)
sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)")
sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)")
sub.add_argument("--code", default="", help="短信验证码(省略则交互式输入)")
sub.set_defaults(func=cmd_phone_login)
# send-code(分步登录第一步)
sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码,保持页面不关闭")
sub.add_argument("--phone", required=True, help="手机号(不含国家码)")
sub.set_defaults(func=cmd_send_code)
# verify-code(分步登录第二步)
sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码并完成登录")
sub.add_argument("--code", required=True, help="收到的短信验证码")
sub.set_defaults(func=cmd_verify_code)
# delete-cookies
sub = subparsers.add_parser("delete-cookies", help="删除 cookies")
sub.set_defaults(func=cmd_delete_cookies)
... ...
... ... @@ -60,6 +60,13 @@ class ContentTooLongError(PublishError):
super().__init__(f"当前输入长度为{current},最大长度为{maximum}")
class RateLimitError(XHSError):
"""请求频率过高,验证码获取失败。"""
def __init__(self) -> None:
super().__init__("请求太频繁,验证码获取失败,请重启浏览器后重试")
class CDPError(XHSError):
"""CDP 通信异常。"""
... ...
... ... @@ -9,8 +9,22 @@ import tempfile
import time
from .cdp import Page
from .errors import RateLimitError
from .human import sleep_random
from .selectors import LOGIN_STATUS, QRCODE_IMG
from .selectors import (
AGREE_CHECKBOX,
AGREE_CHECKBOX_CHECKED,
CODE_INPUT,
GET_CODE_BUTTON,
LOGIN_CONTAINER,
LOGIN_ERR_MSG,
LOGIN_STATUS,
LOGOUT_MENU_ITEM,
LOGOUT_MORE_BUTTON,
PHONE_INPUT,
PHONE_LOGIN_SUBMIT,
QRCODE_IMG,
)
from .urls import EXPLORE_URL
logger = logging.getLogger(__name__)
... ... @@ -84,6 +98,115 @@ def save_qrcode_to_file(src: str) -> str:
return filepath
def send_phone_code(page: Page, phone: str) -> bool:
"""填写手机号并发送短信验证码。
适用于无界面服务器场景,全程通过 CDP 操作,无需扫码。
Args:
page: CDP 页面对象。
phone: 手机号(不含国家码,如 13800138000)。
Returns:
True 验证码已发送,False 已登录(无需再登录)。
Raises:
RuntimeError: 找不到登录表单或手机号输入框。
"""
page.navigate(EXPLORE_URL)
page.wait_for_load()
sleep_random(1500, 2500)
if page.has_element(LOGIN_STATUS):
return False
# 等待登录弹窗出现
page.wait_for_element(LOGIN_CONTAINER, timeout=15.0)
sleep_random(500, 800)
# 点击手机号输入框并逐字输入
page.click_element(PHONE_INPUT)
sleep_random(200, 400)
page.type_text(phone, delay_ms=80)
sleep_random(500, 800)
# 先勾选用户协议,再点获取验证码
if not page.has_element(AGREE_CHECKBOX_CHECKED):
page.click_element(AGREE_CHECKBOX)
sleep_random(300, 600)
# 点击"获取验证码"
page.click_element(GET_CODE_BUTTON)
sleep_random(2000, 2500)
# 检测按钮是否变为倒计时(成功发送后按钮文字会包含数字秒数)
btn_text = page.get_element_text(GET_CODE_BUTTON) or ""
if not any(ch.isdigit() for ch in btn_text):
raise RateLimitError()
logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:])
return True
def submit_phone_code(page: Page, code: str) -> bool:
"""填写短信验证码并提交登录。
Args:
page: CDP 页面对象。
code: 收到的短信验证码。
Returns:
True 登录成功,False 失败(超时或验证码错误)。
"""
# 点击验证码输入框并逐字输入
page.click_element(CODE_INPUT)
sleep_random(300, 500)
page.type_text(code, delay_ms=100)
sleep_random(500, 800)
# 点击登录按钮
page.click_element(PHONE_LOGIN_SUBMIT)
sleep_random(1000, 2000)
# 检查是否有错误提示
err = page.get_element_text(LOGIN_ERR_MSG)
if err and err.strip():
logger.warning("登录失败: %s", err.strip())
return False
return wait_for_login(page, timeout=30.0)
def logout(page: Page) -> bool:
"""通过页面 UI 退出登录(点击"更多"→"退出登录")。
Args:
page: CDP 页面对象。
Returns:
True 退出成功,False 未登录或操作失败。
"""
page.navigate(EXPLORE_URL)
page.wait_for_load()
sleep_random(800, 1500)
if not page.has_element(LOGIN_STATUS):
logger.info("当前未登录,无需退出")
return False
# 点击"更多"按钮展开菜单
page.click_element(LOGOUT_MORE_BUTTON)
sleep_random(500, 800)
# 等待退出菜单项出现并点击
page.wait_for_element(LOGOUT_MENU_ITEM, timeout=5.0)
page.click_element(LOGOUT_MENU_ITEM)
sleep_random(1000, 1500)
logger.info("已退出登录")
return True
def wait_for_login(page: Page, timeout: float = 120.0) -> bool:
"""等待扫码登录完成。
... ...
... ... @@ -4,6 +4,16 @@
LOGIN_STATUS = ".main-container .user .link-wrapper .channel"
QRCODE_IMG = ".login-container .qrcode-img"
# ========== 手机号登录 ==========
LOGIN_CONTAINER = ".login-container"
PHONE_INPUT = "label.phone input"
GET_CODE_BUTTON = "span.code-button"
CODE_INPUT = "label.auth-code input"
PHONE_LOGIN_SUBMIT = ".input-container button.submit"
AGREE_CHECKBOX = ".agree-icon .icon-wrapper"
AGREE_CHECKBOX_CHECKED = ".agree-icon .icon-wrapper.agreed"
LOGIN_ERR_MSG = ".err-msg"
# ========== 首页 / 搜索 ==========
FILTER_BUTTON = "div.filter"
FILTER_PANEL = "div.filter-panel"
... ... @@ -75,5 +85,9 @@ LONG_ARTICLE_TITLE = 'textarea.d-text[placeholder="输入标题"]'
TEMPLATE_CARD = ".template-card"
TEMPLATE_TITLE = ".template-card .template-title"
# ========== 退出登录 ==========
LOGOUT_MORE_BUTTON = "div.information-wrapper"
LOGOUT_MENU_ITEM = 'div.menu-item[data-name="退出登录"]'
# ========== 用户主页 ==========
SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel"
... ...
---
name: xhs-auth
description: |
小红书认证管理技能。检查登录状态、扫码登录、多账号管理。
小红书认证管理技能。检查登录状态、登录(二维码或手机号)、多账号管理。
当用户要求登录小红书、检查登录状态、切换账号时触发。
---
... ... @@ -14,92 +14,68 @@ description: |
按优先级判断用户意图:
1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。
2. 用户要求"登录 / 扫码登录 / 打开登录页":执行登录流程。
2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。
3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 cookie 清除。
## 必做约束
- 登录操作需要用户手动扫码,不可自动化完成。
- 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。
- 需要先有运行中的 Chrome(通过 `scripts/chrome_launcher.py` 启动)。
- 需要先有运行中的 Chrome(`ensure_chrome` 会自动启动)。
- 如果使用文件路径,必须使用绝对路径。
## 工作流程
### 检查登录状态
### 第一步:检查登录状态
```bash
# 默认连接本地 Chrome
python scripts/cli.py check-login
# 指定端口
python scripts/cli.py --port 9222 check-login
# 连接远程 Chrome
python scripts/cli.py --host 10.0.0.12 --port 9222 check-login
```
输出解读:
- `"logged_in": true` + exit code 0 → 已登录,可执行后续操作。
- `"logged_in": false` + exit code 1 → 未登录,提示用户扫码。
- `"logged_in": true` → 已登录,可执行后续操作。
- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,使用二维码登录。
- `"logged_in": false` + `"login_method": "phone"` → 无界面服务器,使用手机验证码登录。
### 登录流程
### 第二步:根据 login_method 选择登录方式
1. 确保 Chrome 已启动(有窗口模式,便于扫码):
```bash
python scripts/chrome_launcher.py
```
#### 方式 A:二维码登录(有界面环境)
2. 获取登录二维码并等待扫码:
```bash
python scripts/cli.py login
```
3. 脚本首先输出一行 JSON,包含 `qrcode_path` 字段(二维码图片保存路径),然后阻塞等待扫码。
1. 命令立即输出 `qrcode_path`(二维码图片路径),然后阻塞等待扫码(最多 120 秒)。
2. 提示用户用小红书 App 或微信扫码。
3. 扫码成功后输出 `"logged_in": true`
4. **展示二维码给用户**:从输出中提取 `qrcode_path`,用系统命令打开图片供用户扫码:
```bash
# macOS
open /tmp/xhs/login_qrcode.png
#### 方式 B:手机验证码登录(无界面服务器,分两步)
# Linux
xdg-open /tmp/xhs/login_qrcode.png
**第一步** — 询问用户手机号后发送验证码:
```bash
python scripts/cli.py send-code --phone <手机号>
```
告知用户:"请用小红书 App 扫描二维码登录"。
5. 用户扫码成功后,脚本自动检测并输出第二行 JSON:`"logged_in": true`
**注意**`login` 命令会阻塞最多 120 秒等待扫码。由于命令阻塞期间无法执行其他操作,应提前在另一个终端或通过后台方式打开图片。推荐流程是先运行 `login` 命令(它会立即输出二维码路径),然后提示用户自行打开图片文件扫码。
### 清除 Cookies(切换账号/退出登录)
- 自动填写手机号、勾选用户协议、点击"获取验证码"。
- Chrome 页面保持打开,等待下一步。
- 输出:`{"status": "code_sent", "message": "验证码已发送至 138****0000,请运行 verify-code --code <验证码>"}`
**第二步** — 询问用户收到的验证码后提交:
```bash
# 清除当前账号 cookies
python scripts/cli.py delete-cookies
# 指定账号清除
python scripts/cli.py --account work delete-cookies
python scripts/cli.py verify-code --code <6位验证码>
```
- 自动填写验证码、点击登录。
- 输出:`{"logged_in": true, "message": "登录成功"}`
### 启动 / 关闭浏览器
### 清除 Cookies(切换账号/退出登录)
```bash
# 启动 Chrome(有窗口,推荐用于登录)
python scripts/chrome_launcher.py
# 无头启动
python scripts/chrome_launcher.py --headless
# 指定端口
python scripts/chrome_launcher.py --port 9223
# 关闭 Chrome
python scripts/chrome_launcher.py --kill
python scripts/cli.py delete-cookies
python scripts/cli.py --account work delete-cookies # 指定账号
```
## 失败处理
- **Chrome 未找到**:提示用户安装 Google Chrome 或设置路径。
- **端口被占用**:提示使用 `--port` 指定其他端口,或先执行 `--kill` 关闭现有实例。
- **扫码超时**:提示用户重新执行登录命令。
- **远程 CDP 连接失败**:检查远程 Chrome 是否已开启调试端口。
- **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。
- **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`
- **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`
- **二维码超时**:重新执行 `login` 命令。
- **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`
... ...