Angiin

perf: 二维码登录流程优化,3步减为2步 + 频率限制自动降级

- fetch_qrcode() 跳过重复导航(页面已在 explore 时省 5-15s)
- fetch_qrcode() 合并 _wait_for_auth_ui + wait_for_element 为单次等待
- check-login 未登录时自动返回二维码(qrcode_data_url + qrcode_path)
- get-qrcode 增加 qrcode_data_url 字段,AI 可直接内嵌 markdown
- send-code 频率限制时自动切换二维码登录(_qrcode_fallback)
- wait_for_login 轮询间隔 0.5s → 0.3s
- 新增 _open_file_if_display:桌面环境自动打开二维码图片
- SKILL.md 方式A 从3步简化为2步(check-login → wait-login)
@@ -81,6 +81,28 @@ def _output(data: dict, exit_code: int = 0) -> None: @@ -81,6 +81,28 @@ def _output(data: dict, exit_code: int = 0) -> None:
81 sys.exit(exit_code) 81 sys.exit(exit_code)
82 82
83 83
  84 +def _open_file_if_display(path: str) -> None:
  85 + """有桌面环境时用系统默认程序打开文件,无界面环境静默跳过。"""
  86 + from chrome_launcher import has_display
  87 +
  88 + if not has_display():
  89 + return
  90 +
  91 + import platform
  92 + import subprocess
  93 +
  94 + try:
  95 + system = platform.system()
  96 + if system == "Windows":
  97 + os.startfile(path)
  98 + elif system == "Darwin":
  99 + subprocess.Popen(["open", path])
  100 + else:
  101 + subprocess.Popen(["xdg-open", path])
  102 + except Exception:
  103 + logger.debug("无法自动打开文件: %s", path)
  104 +
  105 +
84 def _update_account_nickname(args: argparse.Namespace, page) -> None: 106 def _update_account_nickname(args: argparse.Namespace, page) -> None:
85 """登录成功后,将平台昵称写入账号描述(best-effort,失败不影响登录结果)。""" 107 """登录成功后,将平台昵称写入账号描述(best-effort,失败不影响登录结果)。"""
86 if not getattr(args, "account", ""): 108 if not getattr(args, "account", ""):
@@ -229,43 +251,103 @@ def _headless_fallback(port: int) -> None: @@ -229,43 +251,103 @@ def _headless_fallback(port: int) -> None:
229 exit_code=1, 251 exit_code=1,
230 ) 252 )
231 253
  254 +def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None:
  255 + """频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。"""
  256 + from xhs.login import fetch_qrcode, save_qrcode_to_file
  257 + from xhs.urls import EXPLORE_URL
  258 +
  259 + # 刷新页面使登录弹窗回到默认的二维码 tab
  260 + page.navigate(EXPLORE_URL)
  261 + page.wait_for_load()
  262 +
  263 + png_bytes, already = fetch_qrcode(page)
  264 + if already:
  265 + browser.close()
  266 + _output({"logged_in": True, "message": "已登录"})
  267 + return
  268 +
  269 + qrcode_path = save_qrcode_to_file(png_bytes)
  270 +
  271 + import base64 as _b64
  272 + qrcode_data_url = (
  273 + "data:image/png;base64,"
  274 + + _b64.b64encode(png_bytes).decode()
  275 + )
  276 +
  277 + _open_file_if_display(qrcode_path)
  278 +
  279 + _save_login_tab(page.target_id, args.port)
  280 + _clear_session_tab(args.port)
  281 + browser.close()
  282 + _output({
  283 + "logged_in": False,
  284 + "login_method": "qrcode",
  285 + "qrcode_path": qrcode_path,
  286 + "qrcode_data_url": qrcode_data_url,
  287 + "message": (
  288 + "验证码发送受限,已切换为二维码登录,请扫码。"
  289 + "扫码后运行 wait-login 等待登录结果。"
  290 + ),
  291 + }, exit_code=1)
  292 +
232 293
233 # ========== 子命令实现 ========== 294 # ========== 子命令实现 ==========
234 295
235 296
236 def cmd_check_login(args: argparse.Namespace) -> None: 297 def cmd_check_login(args: argparse.Namespace) -> None:
237 - """检查登录状态。"""  
238 - from xhs.login import check_login_status 298 + """检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。"""
  299 + from xhs.login import check_login_status, fetch_qrcode, save_qrcode_to_file
239 300
240 browser, page = _connect(args) 301 browser, page = _connect(args)
241 try: 302 try:
242 logged_in = check_login_status(page) 303 logged_in = check_login_status(page)
243 if logged_in: 304 if logged_in:
244 _output({"logged_in": True}, exit_code=0) 305 _output({"logged_in": True}, exit_code=0)
245 - else:  
246 - import platform 306 + return
  307 +
  308 + # 未登录——当前页面已在登录弹窗,复用页面直接获取二维码
  309 + png_bytes, already = fetch_qrcode(page)
  310 + if already:
  311 + _output({"logged_in": True}, exit_code=0)
  312 + return
  313 +
  314 + qrcode_path = save_qrcode_to_file(png_bytes)
  315 +
  316 + # 生成 data URL,AI 可直接内嵌到 markdown 图片
  317 + import base64 as _b64
  318 + qrcode_data_url = "data:image/png;base64," + _b64.b64encode(png_bytes).decode()
  319 +
  320 + # 记录 login tab + 清除 session tab(与 cmd_get_qrcode 一致)
  321 + _save_login_tab(page.target_id, args.port)
  322 + _clear_session_tab(args.port)
  323 +
  324 + # CLI 终端有桌面时自动打开二维码图片
  325 + _open_file_if_display(qrcode_path)
  326 +
247 from chrome_launcher import has_display 327 from chrome_launcher import has_display
248 - system = platform.system()  
249 328
250 if has_display(): 329 if has_display():
251 - # 所有有界面环境(macOS/Windows/Linux 桌面):二维码显示在对话窗口  
252 _output({ 330 _output({
253 "logged_in": False, 331 "logged_in": False,
254 "login_method": "qrcode", 332 "login_method": "qrcode",
255 - "hint": "请运行 get-qrcode 获取二维码,扫码后运行 wait-login 等待登录结果", 333 + "qrcode_path": qrcode_path,
  334 + "qrcode_data_url": qrcode_data_url,
  335 + "hint": "未登录,二维码已自动生成。扫码后运行 wait-login 等待登录结果",
256 }, exit_code=1) 336 }, exit_code=1)
257 else: 337 else:
258 - # 无界面服务器:二维码或手机验证码均可  
259 _output({ 338 _output({
260 "logged_in": False, 339 "logged_in": False,
261 "login_method": "both", 340 "login_method": "both",
  341 + "qrcode_path": qrcode_path,
  342 + "qrcode_data_url": qrcode_data_url,
262 "hint": ( 343 "hint": (
263 - "方式A: get-qrcode + wait-login(二维码显示在对话窗口);" 344 + "未登录,二维码已自动生成。"
  345 + "方式A: 直接扫码 + wait-login;"
264 "方式B: send-code --phone <手机号> + verify-code(手机验证码)" 346 "方式B: send-code --phone <手机号> + verify-code(手机验证码)"
265 ), 347 ),
266 }, exit_code=1) 348 }, exit_code=1)
267 finally: 349 finally:
268 - # 不关闭 tab,保留页面供下次命令复用(_SESSION_TAB_FILE) 350 + # 只断开 CDP 连接,不关闭 tab——保留登录页面
269 browser.close() 351 browser.close()
270 352
271 353
@@ -281,6 +363,7 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -281,6 +363,7 @@ def cmd_login(args: argparse.Namespace) -> None:
281 return 363 return
282 364
283 qrcode_path = save_qrcode_to_file(png_bytes) 365 qrcode_path = save_qrcode_to_file(png_bytes)
  366 + _open_file_if_display(qrcode_path)
284 print( 367 print(
285 json.dumps( 368 json.dumps(
286 {"qrcode_path": qrcode_path, "message": "请扫码登录,二维码已保存到文件"}, 369 {"qrcode_path": qrcode_path, "message": "请扫码登录,二维码已保存到文件"},
@@ -364,6 +447,13 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -364,6 +447,13 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
364 447
365 qrcode_path = save_qrcode_to_file(png_bytes) 448 qrcode_path = save_qrcode_to_file(png_bytes)
366 449
  450 + # 生成 data URL,AI 可直接内嵌到 markdown 图片
  451 + import base64 as _b64
  452 + qrcode_data_url = "data:image/png;base64," + _b64.b64encode(png_bytes).decode()
  453 +
  454 + # CLI 终端有桌面时自动打开二维码图片
  455 + _open_file_if_display(qrcode_path)
  456 +
367 # 记录 login tab,供 wait-login 精确 reconnect 457 # 记录 login tab,供 wait-login 精确 reconnect
368 _save_login_tab(page.target_id, args.port) 458 _save_login_tab(page.target_id, args.port)
369 # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab 459 # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
@@ -373,7 +463,8 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -373,7 +463,8 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
373 browser.close() 463 browser.close()
374 _output({ 464 _output({
375 "qrcode_path": qrcode_path, 465 "qrcode_path": qrcode_path,
376 - "message": "二维码已生成,请扫码登录。扫码后运行 check-login 确认登录状态。", 466 + "qrcode_data_url": qrcode_data_url,
  467 + "message": "二维码已生成,请扫码登录。扫码后运行 wait-login 等待登录结果。",
377 }) 468 })
378 469
379 470
@@ -402,12 +493,13 @@ def cmd_wait_login(args: argparse.Namespace) -> None: @@ -402,12 +493,13 @@ def cmd_wait_login(args: argparse.Namespace) -> None:
402 493
403 494
404 def cmd_send_code(args: argparse.Namespace) -> None: 495 def cmd_send_code(args: argparse.Namespace) -> None:
405 - """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。"""  
406 - from chrome_launcher import has_display, restart_chrome 496 + """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。
  497 +
  498 + 频率限制时返回错误信息和建议,由 AI 告知用户选择。
  499 + """
407 from xhs.errors import RateLimitError 500 from xhs.errors import RateLimitError
408 from xhs.login import send_phone_code 501 from xhs.login import send_phone_code
409 502
410 - for attempt in range(2):  
411 browser, page = _connect(args) 503 browser, page = _connect(args)
412 try: 504 try:
413 sent = send_phone_code(page, args.phone) 505 sent = send_phone_code(page, args.phone)
@@ -421,19 +513,18 @@ def cmd_send_code(args: argparse.Namespace) -> None: @@ -421,19 +513,18 @@ def cmd_send_code(args: argparse.Namespace) -> None:
421 _clear_session_tab(args.port) 513 _clear_session_tab(args.port)
422 _output({ 514 _output({
423 "status": "code_sent", 515 "status": "code_sent",
424 - "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>", 516 + "message": (
  517 + f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},"
  518 + "请运行 verify-code --code <验证码>"
  519 + ),
425 }) 520 })
426 except RateLimitError: 521 except RateLimitError:
427 - browser.close()  
428 - if attempt == 0:  
429 - logger.info("请求频率限制,重启 Chrome 后重试...")  
430 - restart_chrome(port=args.port, headless=not has_display())  
431 - continue  
432 - _output({"success": False, "error": "请求太频繁,重启后仍失败,请稍后再试"}, exit_code=2) 522 + # 频率限制——直接切换二维码登录
  523 + logger.info("验证码发送受限,切换为二维码登录")
  524 + _qrcode_fallback(browser, page, args)
433 else: 525 else:
434 # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用 526 # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用
435 browser.close() 527 browser.close()
436 - return  
437 528
438 529
439 def cmd_verify_code(args: argparse.Namespace) -> None: 530 def cmd_verify_code(args: argparse.Namespace) -> None:
@@ -118,15 +118,18 @@ def fetch_qrcode(page: Page) -> tuple[bytes, bool]: @@ -118,15 +118,18 @@ def fetch_qrcode(page: Page) -> tuple[bytes, bool]:
118 - 如果已登录,返回 (b"", True) 118 - 如果已登录,返回 (b"", True)
119 - 如果未登录,返回 (png_bytes, False) 119 - 如果未登录,返回 (png_bytes, False)
120 """ 120 """
  121 + # 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航
  122 + current_url = page.evaluate("location.href") or ""
  123 + if "explore" not in current_url:
121 page.navigate(EXPLORE_URL) 124 page.navigate(EXPLORE_URL)
122 page.wait_for_load() 125 page.wait_for_load()
123 - _wait_for_auth_ui(page)  
124 126
  127 + # 快速检查是否已登录,避免无谓等待二维码
125 if page.has_element(LOGIN_STATUS): 128 if page.has_element(LOGIN_STATUS):
126 return b"", True 129 return b"", True
127 130
128 - # 等待 img.qrcode-img 出现,用浏览器 Canvas 加白边后导出 PNG base64  
129 - page.wait_for_element(QRCODE_IMG, timeout=10.0) 131 + # 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑
  132 + page.wait_for_element(QRCODE_IMG, timeout=15.0)
130 b64 = page.evaluate( 133 b64 = page.evaluate(
131 f""" 134 f"""
132 (() => {{ 135 (() => {{
@@ -307,5 +310,5 @@ def wait_for_login(page: Page, timeout: float = 120.0) -> bool: @@ -307,5 +310,5 @@ def wait_for_login(page: Page, timeout: float = 120.0) -> bool:
307 if page.has_element(LOGIN_STATUS): 310 if page.has_element(LOGIN_STATUS):
308 logger.info("登录成功") 311 logger.info("登录成功")
309 return True 312 return True
310 - time.sleep(0.5) 313 + time.sleep(0.3)
311 return False 314 return False
@@ -89,24 +89,16 @@ python scripts/cli.py check-login @@ -89,24 +89,16 @@ python scripts/cli.py check-login
89 89
90 输出解读: 90 输出解读:
91 - `"logged_in": true` → 已登录,可执行后续操作。 91 - `"logged_in": true` → 已登录,可执行后续操作。
92 -- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,走方式 A(二维码)。  
93 -- `"logged_in": false` + `"login_method": "both"` → 无界面服务器,**询问用户选方式 A(二维码)或方式 B(手机验证码)** 92 +- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,走方式 A(二维码)。输出自动包含 `qrcode_data_url` 和 `qrcode_path`
  93 +- `"logged_in": false` + `"login_method": "both"` → 无界面服务器,输出自动包含二维码,**询问用户选方式 A(二维码)或方式 B(手机验证码)**
94 94
95 ### 第二步:根据输出选择登录方式 95 ### 第二步:根据输出选择登录方式
96 96
97 #### 方式 A:二维码登录(所有平台通用) 97 #### 方式 A:二维码登录(所有平台通用)
98 98
99 -**第一步** — 获取二维码(非阻塞,立即返回): 99 +> `check-login` 未登录时会自动返回二维码(`qrcode_data_url` + `qrcode_path`),无需单独调 `get-qrcode`。
100 100
101 -```bash  
102 -python scripts/cli.py get-qrcode  
103 -```  
104 -  
105 -- Chrome 正常启动,从登录弹窗 `img` 元素读取二维码(相当于右键另存为)。  
106 -- 命令立即退出,Chrome tab 保持打开(QR 会话继续有效)。  
107 -- 输出:`{"qrcode_path": "...", "qrcode_data_url": "data:image/png;base64,...", "message": "..."}`  
108 -  
109 -**第二步** — 从 JSON 取 `qrcode_data_url`,在回复中直接写出: 101 +**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_data_url`,在回复中直接写出:
110 102
111 ``` 103 ```
112 ![小红书登录二维码]({qrcode_data_url}) 104 ![小红书登录二维码]({qrcode_data_url})
@@ -114,14 +106,16 @@ python scripts/cli.py get-qrcode @@ -114,14 +106,16 @@ python scripts/cli.py get-qrcode
114 106
115 图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。 107 图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。
116 108
117 -**第步** — 等待登录完成(**单次调用,无需轮询**): 109 +**第步** — 等待登录完成(**单次调用,无需轮询**):
118 110
119 ```bash 111 ```bash
120 python scripts/cli.py wait-login 112 python scripts/cli.py wait-login
121 ``` 113 ```
122 114
123 - 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。 115 - 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。
124 -- 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 116 +- 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 刷新二维码。
  117 +
  118 +> **二维码过期刷新**:如需单独刷新二维码(如超时后),可运行 `get-qrcode`,它仍作为独立命令保留。
125 119
126 #### 方式 B:手机验证码登录(无界面服务器,分两步) 120 #### 方式 B:手机验证码登录(无界面服务器,分两步)
127 121
@@ -140,7 +134,8 @@ python scripts/cli.py send-code --phone <用户确认的手机号> @@ -140,7 +134,8 @@ python scripts/cli.py send-code --phone <用户确认的手机号>
140 ``` 134 ```
141 - 自动填写手机号、勾选用户协议、点击"获取验证码"。 135 - 自动填写手机号、勾选用户协议、点击"获取验证码"。
142 - Chrome 页面保持打开,等待下一步。 136 - Chrome 页面保持打开,等待下一步。
143 -- 输出:`{"status": "code_sent", "message": "验证码已发送至 138****0000,请运行 verify-code --code <验证码>"}` 137 +- 正常输出:`{"status": "code_sent", "message": "..."}`
  138 +- **频率限制**:自动切换为二维码登录,输出含 `qrcode_data_url`。告知用户"验证码发送受限,已切换为二维码登录",展示二维码,然后运行 `wait-login`
144 139
145 **第二步** — 向用户询问验证码,然后提交登录: 140 **第二步** — 向用户询问验证码,然后提交登录:
146 141