You need to sign in or sign up before continuing.
Angiin
Committed by GitHub

Merge pull request #23 from autoclaw-cc/perf/qrcode-login-optimization

性能优化

- 优化二维码获取流程:当页面已经在 explore 页面时跳过重复导航,二维码获取速度提升约 5–15 秒。
- 合并登录界面等待逻辑:将 `_wait_for_auth_ui` 与 `wait_for_element` 合并为一次等待,减少不必要的等待时间。
- `wait_for_login` 登录检测轮询间隔从 0.5 秒缩短到 0.3 秒,登录状态识别更快。

二维码登录体验改进

- `check-login` 在未登录时会自动返回二维码信息(`qrcode_data_url` 与 `qrcode_path`),无需额外请求。
- `get-qrcode` 新增 `qrcode_data_url` 字段,AI 或前端可以直接以内嵌 Markdown 的方式展示二维码。
- 新增 `_open_file_if_display`:在有桌面环境时自动打开二维码图片,方便直接扫码。
- QR 码生成优化:使用 goqr.me API 替代 base64 方案,并消除重复导航。

登录流程简化

- `SKILL.md` 中的方式 A 登录流程从 3 步简化为 2 步:
  - `check-login`
  - `wait-login`

自动降级机制

当手机验证码登录触发频率限制时:

- `send-code` 会自动切换到二维码登录(`_qrcode_fallback`)。
- `check-login` 也会在未登录时直接返回二维码,避免额外操作。

标题长度检测强化

- 修复标题长度超过小红书限制时,发布失败但仍显示发布成功的问题。

问题修复

- 修复 `cmd_phone_login` 在验证码发送频率限制时未正确捕获异常的问题。
- 新增 `_qrcode_fallback`,在此类情况下自动降级为二维码登录。
@@ -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,105 @@ def _headless_fallback(port: int) -> None: @@ -229,43 +251,105 @@ 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 (
  257 + fetch_qrcode,
  258 + make_qrcode_url,
  259 + save_qrcode_to_file,
  260 + )
  261 + from xhs.urls import EXPLORE_URL
  262 +
  263 + # 刷新页面使登录弹窗回到默认的二维码 tab
  264 + page.navigate(EXPLORE_URL)
  265 + page.wait_for_load()
  266 +
  267 + png_bytes, _b64_orig, already = fetch_qrcode(page)
  268 + if already:
  269 + browser.close()
  270 + _output({"logged_in": True, "message": "已登录"})
  271 + return
  272 +
  273 + qrcode_path = save_qrcode_to_file(png_bytes)
  274 + image_url, login_url = make_qrcode_url(png_bytes)
  275 +
  276 + _open_file_if_display(qrcode_path)
  277 +
  278 + _save_login_tab(page.target_id, args.port)
  279 + _clear_session_tab(args.port)
  280 + browser.close()
  281 + result: dict = {
  282 + "logged_in": False,
  283 + "login_method": "qrcode",
  284 + "qrcode_path": qrcode_path,
  285 + "qrcode_image_url": image_url,
  286 + "message": (
  287 + "验证码发送受限,已切换为二维码登录,请扫码。"
  288 + "扫码后运行 wait-login 等待登录结果。"
  289 + ),
  290 + }
  291 + if login_url:
  292 + result["qr_login_url"] = login_url
  293 + _output(result, exit_code=1)
  294 +
232 295
233 # ========== 子命令实现 ========== 296 # ========== 子命令实现 ==========
234 297
235 298
236 def cmd_check_login(args: argparse.Namespace) -> None: 299 def cmd_check_login(args: argparse.Namespace) -> None:
237 - """检查登录状态。"""  
238 - from xhs.login import check_login_status 300 + """检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。
  301 +
  302 + 直接调 fetch_qrcode 一步完成:导航 + 登录检查 + 二维码获取,
  303 + 不再经过 check_login_status 避免重复导航和等待。
  304 + """
  305 + from xhs.login import (
  306 + fetch_qrcode,
  307 + make_qrcode_url,
  308 + save_qrcode_to_file,
  309 + )
239 310
240 browser, page = _connect(args) 311 browser, page = _connect(args)
241 try: 312 try:
242 - logged_in = check_login_status(page)  
243 - if logged_in: 313 + png_bytes, _b64_orig, already = fetch_qrcode(page)
  314 + if already:
244 _output({"logged_in": True}, exit_code=0) 315 _output({"logged_in": True}, exit_code=0)
  316 + return
  317 +
  318 + qrcode_path = save_qrcode_to_file(png_bytes)
  319 + image_url, login_url = make_qrcode_url(png_bytes)
  320 +
  321 + # 记录 login tab + 清除 session tab
  322 + _save_login_tab(page.target_id, args.port)
  323 + _clear_session_tab(args.port)
  324 +
  325 + _open_file_if_display(qrcode_path)
  326 +
  327 + from chrome_launcher import has_display
  328 +
  329 + result: dict = {
  330 + "logged_in": False,
  331 + "qrcode_path": qrcode_path,
  332 + "qrcode_image_url": image_url,
  333 + }
  334 + if login_url:
  335 + result["qr_login_url"] = login_url
  336 + if has_display():
  337 + result["login_method"] = "qrcode"
  338 + result["hint"] = (
  339 + "未登录,二维码已自动生成。"
  340 + "扫码后运行 wait-login 等待登录结果"
  341 + )
245 else: 342 else:
246 - import platform  
247 - from chrome_launcher import has_display  
248 - system = platform.system()  
249 -  
250 - if has_display():  
251 - # 所有有界面环境(macOS/Windows/Linux 桌面):二维码显示在对话窗口  
252 - _output({  
253 - "logged_in": False,  
254 - "login_method": "qrcode",  
255 - "hint": "请运行 get-qrcode 获取二维码,扫码后运行 wait-login 等待登录结果",  
256 - }, exit_code=1)  
257 - else:  
258 - # 无界面服务器:二维码或手机验证码均可  
259 - _output({  
260 - "logged_in": False,  
261 - "login_method": "both",  
262 - "hint": (  
263 - "方式A: get-qrcode + wait-login(二维码显示在对话窗口);"  
264 - "方式B: send-code --phone <手机号> + verify-code(手机验证码)"  
265 - ),  
266 - }, exit_code=1) 343 + result["login_method"] = "both"
  344 + result["hint"] = (
  345 + "未登录,二维码已自动生成。"
  346 + "方式A: 直接扫码 + wait-login;"
  347 + "方式B: send-code --phone <手机号>"
  348 + " + verify-code(手机验证码)"
  349 + )
  350 + _output(result, exit_code=1)
267 finally: 351 finally:
268 - # 不关闭 tab,保留页面供下次命令复用(_SESSION_TAB_FILE) 352 + # 只断开 CDP 连接,不关闭 tab——保留登录页面
269 browser.close() 353 browser.close()
270 354
271 355
@@ -275,15 +359,16 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -275,15 +359,16 @@ def cmd_login(args: argparse.Namespace) -> None:
275 359
276 browser, page = _connect(args) 360 browser, page = _connect(args)
277 try: 361 try:
278 - png_bytes, already = fetch_qrcode(page) 362 + png_bytes, _b64, already = fetch_qrcode(page)
279 if already: 363 if already:
280 _output({"logged_in": True, "message": "已登录"}) 364 _output({"logged_in": True, "message": "已登录"})
281 return 365 return
282 366
283 qrcode_path = save_qrcode_to_file(png_bytes) 367 qrcode_path = save_qrcode_to_file(png_bytes)
  368 + _open_file_if_display(qrcode_path)
284 print( 369 print(
285 json.dumps( 370 json.dumps(
286 - {"qrcode_path": qrcode_path, "message": "请扫码登录,二维码已保存到文件"}, 371 + {"qrcode_path": qrcode_path, "message": "请扫码登录"},
287 ensure_ascii=False, 372 ensure_ascii=False,
288 ) 373 )
289 ) 374 )
@@ -301,11 +386,19 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -301,11 +386,19 @@ def cmd_login(args: argparse.Namespace) -> None:
301 386
302 def cmd_phone_login(args: argparse.Namespace) -> None: 387 def cmd_phone_login(args: argparse.Namespace) -> None:
303 """手机号+验证码登录(适用于无界面服务器)。""" 388 """手机号+验证码登录(适用于无界面服务器)。"""
  389 + from xhs.errors import RateLimitError
304 from xhs.login import send_phone_code, submit_phone_code 390 from xhs.login import send_phone_code, submit_phone_code
305 391
306 browser, page = _connect(args) 392 browser, page = _connect(args)
307 try: 393 try:
308 sent = send_phone_code(page, args.phone) 394 sent = send_phone_code(page, args.phone)
  395 + except RateLimitError:
  396 + # 频率限制——直接切换二维码登录
  397 + logger.info("验证码发送受限,切换为二维码登录")
  398 + _qrcode_fallback(browser, page, args)
  399 + return
  400 +
  401 + try:
309 if not sent: 402 if not sent:
310 _output({"logged_in": True, "message": "已登录,无需重新登录"}) 403 _output({"logged_in": True, "message": "已登录,无需重新登录"})
311 return 404 return
@@ -313,7 +406,13 @@ def cmd_phone_login(args: argparse.Namespace) -> None: @@ -313,7 +406,13 @@ def cmd_phone_login(args: argparse.Namespace) -> None:
313 # 输出提示,等待用户在终端输入验证码 406 # 输出提示,等待用户在终端输入验证码
314 print( 407 print(
315 json.dumps( 408 json.dumps(
316 - {"status": "code_sent", "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]}"}, 409 + {
  410 + "status": "code_sent",
  411 + "message": (
  412 + f"验证码已发送至 "
  413 + f"{args.phone[:3]}****{args.phone[-4:]}"
  414 + ),
  415 + },
317 ensure_ascii=False, 416 ensure_ascii=False,
318 ), 417 ),
319 flush=True, 418 flush=True,
@@ -326,16 +425,25 @@ def cmd_phone_login(args: argparse.Namespace) -> None: @@ -326,16 +425,25 @@ def cmd_phone_login(args: argparse.Namespace) -> None:
326 try: 425 try:
327 code = input("请输入验证码: ").strip() 426 code = input("请输入验证码: ").strip()
328 except EOFError: 427 except EOFError:
329 - _output({"success": False, "error": "未收到验证码输入"}, exit_code=2) 428 + _output(
  429 + {"success": False, "error": "未收到验证码输入"},
  430 + exit_code=2,
  431 + )
330 return 432 return
331 433
332 if not code: 434 if not code:
333 - _output({"success": False, "error": "验证码不能为空"}, exit_code=2) 435 + _output(
  436 + {"success": False, "error": "验证码不能为空"},
  437 + exit_code=2,
  438 + )
334 return 439 return
335 440
336 success = submit_phone_code(page, code) 441 success = submit_phone_code(page, code)
337 _output( 442 _output(
338 - {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, 443 + {
  444 + "logged_in": success,
  445 + "message": "登录成功" if success else "验证码错误或超时",
  446 + },
339 exit_code=0 if success else 2, 447 exit_code=0 if success else 2,
340 ) 448 )
341 finally: 449 finally:
@@ -351,11 +459,15 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -351,11 +459,15 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
351 调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境) 459 调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境)
352 也会显示二维码,用户可选择扫任意一个。 460 也会显示二维码,用户可选择扫任意一个。
353 """ 461 """
354 - from xhs.login import fetch_qrcode, save_qrcode_to_file 462 + from xhs.login import (
  463 + fetch_qrcode,
  464 + make_qrcode_url,
  465 + save_qrcode_to_file,
  466 + )
355 467
356 browser, page = _connect(args) 468 browser, page = _connect(args)
357 469
358 - png_bytes, already = fetch_qrcode(page) 470 + png_bytes, _b64_orig, already = fetch_qrcode(page)
359 if already: 471 if already:
360 browser.close_page(page) 472 browser.close_page(page)
361 browser.close() 473 browser.close()
@@ -363,18 +475,26 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -363,18 +475,26 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
363 return 475 return
364 476
365 qrcode_path = save_qrcode_to_file(png_bytes) 477 qrcode_path = save_qrcode_to_file(png_bytes)
  478 + image_url, login_url = make_qrcode_url(png_bytes)
  479 +
  480 + _open_file_if_display(qrcode_path)
366 481
367 # 记录 login tab,供 wait-login 精确 reconnect 482 # 记录 login tab,供 wait-login 精确 reconnect
368 _save_login_tab(page.target_id, args.port) 483 _save_login_tab(page.target_id, args.port)
369 - # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab 484 + # 清除 session tab 引用——隔离登录表单,防止其他命令复用
370 _clear_session_tab(args.port) 485 _clear_session_tab(args.port)
371 486
372 - # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码 487 + # 只断开 CDP 连接,不关闭 tab——QR 会话保持
373 browser.close() 488 browser.close()
374 - _output({ 489 + result: dict = {
375 "qrcode_path": qrcode_path, 490 "qrcode_path": qrcode_path,
376 - "message": "二维码已生成,请扫码登录。扫码后运行 check-login 确认登录状态。",  
377 - }) 491 + "qrcode_image_url": image_url,
  492 + "message": "二维码已生成,请扫码登录。"
  493 + "扫码后运行 wait-login 等待登录结果。",
  494 + }
  495 + if login_url:
  496 + result["qr_login_url"] = login_url
  497 + _output(result)
378 498
379 499
380 def cmd_wait_login(args: argparse.Namespace) -> None: 500 def cmd_wait_login(args: argparse.Namespace) -> None:
@@ -402,39 +522,39 @@ def cmd_wait_login(args: argparse.Namespace) -> None: @@ -402,39 +522,39 @@ def cmd_wait_login(args: argparse.Namespace) -> None:
402 522
403 523
404 def cmd_send_code(args: argparse.Namespace) -> None: 524 def cmd_send_code(args: argparse.Namespace) -> None:
405 - """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。"""  
406 - from chrome_launcher import has_display, restart_chrome 525 + """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。
  526 +
  527 + 频率限制时返回错误信息和建议,由 AI 告知用户选择。
  528 + """
407 from xhs.errors import RateLimitError 529 from xhs.errors import RateLimitError
408 from xhs.login import send_phone_code 530 from xhs.login import send_phone_code
409 531
410 - for attempt in range(2):  
411 - browser, page = _connect(args)  
412 - try:  
413 - sent = send_phone_code(page, args.phone)  
414 - if not sent:  
415 - _output({"logged_in": True, "message": "已登录,无需重新登录"})  
416 - return  
417 -  
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)  
422 - _output({  
423 - "status": "code_sent",  
424 - "message": f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},请运行 verify-code --code <验证码>",  
425 - })  
426 - 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)  
433 - else:  
434 - # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用  
435 - browser.close() 532 + browser, page = _connect(args)
  533 + try:
  534 + sent = send_phone_code(page, args.phone)
  535 + if not sent:
  536 + _output({"logged_in": True, "message": "已登录,无需重新登录"})
436 return 537 return
437 538
  539 + # 记录 login tab,供 verify-code 精确 reconnect
  540 + _save_login_tab(page.target_id, args.port)
  541 + # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
  542 + _clear_session_tab(args.port)
  543 + _output({
  544 + "status": "code_sent",
  545 + "message": (
  546 + f"验证码已发送至 {args.phone[:3]}****{args.phone[-4:]},"
  547 + "请运行 verify-code --code <验证码>"
  548 + ),
  549 + })
  550 + except RateLimitError:
  551 + # 频率限制——直接切换二维码登录
  552 + logger.info("验证码发送受限,切换为二维码登录")
  553 + _qrcode_fallback(browser, page, args)
  554 + else:
  555 + # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用
  556 + browser.close()
  557 +
438 558
439 def cmd_verify_code(args: argparse.Namespace) -> None: 559 def cmd_verify_code(args: argparse.Namespace) -> None:
440 """分步登录第二步:在已有页面上填写验证码并提交。""" 560 """分步登录第二步:在已有页面上填写验证码并提交。"""
@@ -10,7 +10,6 @@ import time @@ -10,7 +10,6 @@ import time
10 10
11 _QR_DIR = os.path.join(tempfile.gettempdir(), "xhs") 11 _QR_DIR = os.path.join(tempfile.gettempdir(), "xhs")
12 _QR_FILE = os.path.join(_QR_DIR, "login_qrcode.png") 12 _QR_FILE = os.path.join(_QR_DIR, "login_qrcode.png")
13 -_QR_BORDER = 16 # 截图时在元素四周留白的像素数  
14 13
15 from .cdp import Page 14 from .cdp import Page
16 from .errors import RateLimitError 15 from .errors import RateLimitError
@@ -50,18 +49,6 @@ def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None: @@ -50,18 +49,6 @@ def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None:
50 raise RateLimitError() 49 raise RateLimitError()
51 50
52 51
53 -def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:  
54 - """等待认证 UI 出现,替代固定延迟。  
55 -  
56 - 轮询直到登录状态指示器或登录容器出现为止,避免无谓等待。  
57 - 超时后静默返回,由调用方自行处理元素不存在的情况。  
58 - """  
59 - deadline = time.monotonic() + timeout  
60 - while time.monotonic() < deadline:  
61 - if page.has_element(LOGIN_STATUS) or page.has_element(LOGIN_CONTAINER):  
62 - return  
63 - time.sleep(0.2)  
64 -  
65 52
66 def get_current_user_nickname(page: Page) -> str: 53 def get_current_user_nickname(page: Page) -> str:
67 """获取当前登录用户的真实昵称,失败时返回空字符串(best-effort)。 54 """获取当前登录用户的真实昵称,失败时返回空字符串(best-effort)。
@@ -71,8 +58,7 @@ def get_current_user_nickname(page: Page) -> str: @@ -71,8 +58,7 @@ def get_current_user_nickname(page: Page) -> str:
71 try: 58 try:
72 page.navigate(EXPLORE_URL) 59 page.navigate(EXPLORE_URL)
73 page.wait_for_load() 60 page.wait_for_load()
74 - _wait_for_auth_ui(page)  
75 - if not page.has_element(LOGIN_STATUS): 61 + if not check_login_status(page):
76 return "" 62 return ""
77 63
78 # 从导航栏"我"的链接取个人主页 URL(含 /user/profile/<user_id>) 64 # 从导航栏"我"的链接取个人主页 URL(含 /user/profile/<user_id>)
@@ -103,53 +89,130 @@ def check_login_status(page: Page) -> bool: @@ -103,53 +89,130 @@ def check_login_status(page: Page) -> bool:
103 Returns: 89 Returns:
104 True 已登录,False 未登录。 90 True 已登录,False 未登录。
105 """ 91 """
106 - page.navigate(EXPLORE_URL)  
107 - page.wait_for_load()  
108 - _wait_for_auth_ui(page) 92 + # 如果当前页面已在 explore,跳过重复导航
  93 + current_url = page.evaluate("location.href") or ""
  94 + if "explore" not in current_url:
  95 + page.navigate(EXPLORE_URL)
  96 + page.wait_for_load()
  97 +
  98 + # 直接等待登录状态或登录容器出现,替代 _wait_for_auth_ui
  99 + deadline = time.monotonic() + 10.0
  100 + while time.monotonic() < deadline:
  101 + if page.has_element(LOGIN_STATUS):
  102 + return True
  103 + if page.has_element(LOGIN_CONTAINER):
  104 + return False
  105 + time.sleep(0.2)
  106 + return False
109 107
110 - return page.has_element(LOGIN_STATUS)  
111 108
  109 +def fetch_qrcode(page: Page) -> tuple[bytes, str, bool]:
  110 + """获取登录二维码图片。
112 111
113 -def fetch_qrcode(page: Page) -> tuple[bytes, bool]:  
114 - """截取登录二维码图片(CDP 元素截图)。 112 + 直接读取 img.src(data:image/png;base64,...),跳过 Canvas 绘制。
115 113
116 Returns: 114 Returns:
117 - (png_bytes, already_logged_in)  
118 - - 如果已登录,返回 (b"", True)  
119 - - 如果未登录,返回 (png_bytes, False) 115 + (png_bytes, b64_str, already_logged_in)
  116 + - 如果已登录,返回 (b"", "", True)
  117 + - 如果未登录,返回 (png_bytes, b64_str, False)
120 """ 118 """
121 - page.navigate(EXPLORE_URL)  
122 - page.wait_for_load()  
123 - _wait_for_auth_ui(page) 119 + # 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航
  120 + current_url = page.evaluate("location.href") or ""
  121 + if "explore" not in current_url:
  122 + page.navigate(EXPLORE_URL)
  123 + page.wait_for_load()
124 124
  125 + # 快速检查是否已登录,避免无谓等待二维码
125 if page.has_element(LOGIN_STATUS): 126 if page.has_element(LOGIN_STATUS):
126 - return b"", True  
127 -  
128 - # 等待 img.qrcode-img 出现,用浏览器 Canvas 加白边后导出 PNG base64  
129 - page.wait_for_element(QRCODE_IMG, timeout=10.0)  
130 - b64 = page.evaluate(  
131 - f"""  
132 - (() => {{  
133 - const img = document.querySelector({json.dumps(QRCODE_IMG)});  
134 - if (!img) return null;  
135 - const p = {_QR_BORDER};  
136 - const c = document.createElement('canvas');  
137 - c.width = img.naturalWidth + p * 2;  
138 - c.height = img.naturalHeight + p * 2;  
139 - const ctx = c.getContext('2d');  
140 - ctx.fillStyle = '#ffffff';  
141 - ctx.fillRect(0, 0, c.width, c.height);  
142 - ctx.drawImage(img, p, p);  
143 - return c.toDataURL('image/png').split(',')[1];  
144 - }})()  
145 - """ 127 + return b"", "", True
  128 +
  129 + # 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑
  130 + page.wait_for_element(QRCODE_IMG, timeout=15.0)
  131 +
  132 + # img.src 本身就是 data:image/png;base64,...,直接读取
  133 + src = page.evaluate(
  134 + f"document.querySelector({json.dumps(QRCODE_IMG)})?.src || ''"
146 ) 135 )
147 - if not b64:  
148 - raise RuntimeError("二维码 Canvas 导出失败") 136 + if not src or "base64," not in src:
  137 + raise RuntimeError("二维码图片 src 读取失败")
  138 +
  139 + b64_str = src.split("base64,", 1)[1]
  140 +
149 import base64 141 import base64
150 - png_bytes = base64.b64decode(b64) 142 + png_bytes = base64.b64decode(b64_str)
151 143
152 - return png_bytes, False 144 + return png_bytes, b64_str, False
  145 +
  146 +
  147 +def _decode_qr_content(png_bytes: bytes) -> str | None:
  148 + """通过 goqr.me read API 解码二维码内容。
  149 +
  150 + Returns:
  151 + 解码后的文本(通常是登录 URL),失败返回 None。
  152 + """
  153 + import http.client
  154 +
  155 + boundary = "----XhsQrBoundary"
  156 + body = (
  157 + f"--{boundary}\r\n"
  158 + f'Content-Disposition: form-data; name="file";'
  159 + f' filename="qr.png"\r\n'
  160 + f"Content-Type: image/png\r\n\r\n"
  161 + ).encode() + png_bytes + f"\r\n--{boundary}--\r\n".encode()
  162 +
  163 + try:
  164 + conn = http.client.HTTPSConnection(
  165 + "api.qrserver.com", timeout=5
  166 + )
  167 + conn.request(
  168 + "POST",
  169 + "/v1/read-qr-code/",
  170 + body=body,
  171 + headers={
  172 + "Content-Type": (
  173 + f"multipart/form-data; boundary={boundary}"
  174 + ),
  175 + },
  176 + )
  177 + resp = conn.getresponse()
  178 + if resp.status != 200:
  179 + return None
  180 + result = json.loads(resp.read().decode())
  181 + data = result[0]["symbol"][0].get("data")
  182 + return data if data else None
  183 + except Exception:
  184 + logger.debug("goqr.me 解码失败,将使用 base64 fallback")
  185 + return None
  186 +
  187 +
  188 +def make_qrcode_url(
  189 + png_bytes: bytes,
  190 +) -> tuple[str, str | None]:
  191 + """生成二维码展示 URL 和登录链接。
  192 +
  193 + 通过 goqr.me read API 解码 QR 内容,构造 API 图片 URL
  194 + (~270 字符)和小红书官方登录链接。
  195 +
  196 + Returns:
  197 + (image_url, login_url)
  198 + - image_url: 可用于 markdown 图片的 URL
  199 + - login_url: 小红书官方登录链接(解码失败时为 None)
  200 + """
  201 + import base64
  202 + import urllib.parse
  203 +
  204 + qr_content = _decode_qr_content(png_bytes)
  205 + if qr_content:
  206 + image_url = (
  207 + "https://api.qrserver.com/v1/create-qr-code/"
  208 + "?size=300x300&data="
  209 + + urllib.parse.quote(qr_content, safe="")
  210 + )
  211 + return image_url, qr_content
  212 +
  213 + # fallback: base64 data URL
  214 + b64 = base64.b64encode(png_bytes).decode()
  215 + return "data:image/png;base64," + b64, None
153 216
154 217
155 def save_qrcode_to_file(png_bytes: bytes) -> str: 218 def save_qrcode_to_file(png_bytes: bytes) -> str:
@@ -183,8 +246,11 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -183,8 +246,11 @@ def send_phone_code(page: Page, phone: str) -> bool:
183 Raises: 246 Raises:
184 RuntimeError: 找不到登录表单或手机号输入框。 247 RuntimeError: 找不到登录表单或手机号输入框。
185 """ 248 """
186 - page.navigate(EXPLORE_URL)  
187 - page.wait_for_load() 249 + # 如果当前页面已在 explore,跳过重复导航
  250 + current_url = page.evaluate("location.href") or ""
  251 + if "explore" not in current_url:
  252 + page.navigate(EXPLORE_URL)
  253 + page.wait_for_load()
188 254
189 # 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待) 255 # 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待)
190 try: 256 try:
@@ -307,5 +373,5 @@ def wait_for_login(page: Page, timeout: float = 120.0) -> bool: @@ -307,5 +373,5 @@ def wait_for_login(page: Page, timeout: float = 120.0) -> bool:
307 if page.has_element(LOGIN_STATUS): 373 if page.has_element(LOGIN_STATUS):
308 logger.info("登录成功") 374 logger.info("登录成功")
309 return True 375 return True
310 - time.sleep(0.5) 376 + time.sleep(0.3)
311 return False 377 return False
@@ -321,7 +321,13 @@ def _fill_publish_form( @@ -321,7 +321,13 @@ def _fill_publish_form(
321 # 从正文末尾提取 hashtag 并合并到 tags 321 # 从正文末尾提取 hashtag 并合并到 tags
322 content, tags = _extract_hashtags_from_content(content, tags) 322 content, tags = _extract_hashtags_from_content(content, tags)
323 323
324 - # 标题 324 + # 标题——填写前先校验长度,超限直接报错(由 AI 重新生成标题)
  325 + from title_utils import calc_title_length
  326 +
  327 + title_len = calc_title_length(title)
  328 + if title_len > 20:
  329 + raise TitleTooLongError(str(title_len), "20")
  330 +
325 page.input_text(TITLE_INPUT, title) 331 page.input_text(TITLE_INPUT, title)
326 time.sleep(0.5) 332 time.sleep(0.5)
327 _check_title_max_length(page) 333 _check_title_max_length(page)
@@ -71,7 +71,7 @@ python scripts/cli.py list-accounts @@ -71,7 +71,7 @@ python scripts/cli.py list-accounts
71 71
72 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。 72 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。
73 2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。 73 2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。
74 -3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 cookie 清除 74 +3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 `delete-cookies`(内部自动先 UI 退出登录,再清除本地 cookies)
75 75
76 ## 必做约束 76 ## 必做约束
77 77
@@ -89,39 +89,43 @@ python scripts/cli.py check-login @@ -89,39 +89,43 @@ 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_image_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_image_url` + `qrcode_path`),无需单独调 `get-qrcode`。
100 100
101 -```bash  
102 -python scripts/cli.py get-qrcode  
103 -``` 101 +**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_image_url`,在回复中展示:
104 102
105 -- Chrome 正常启动,从登录弹窗 `img` 元素读取二维码(相当于右键另存为)。  
106 -- 命令立即退出,Chrome tab 保持打开(QR 会话继续有效)。  
107 -- 输出:`{"qrcode_path": "...", "qrcode_data_url": "data:image/png;base64,...", "message": "..."}` 103 +```
  104 +请使用小红书 App 扫描以下二维码登录:
108 105
109 -**第二步** — 从 JSON 取 `qrcode_data_url`,在回复中直接写出: 106 +![小红书登录二维码]({qrcode_image_url})
110 107
111 -```  
112 -![小红书登录二维码]({qrcode_data_url}) 108 +您也可以在手机浏览器中直接访问此链接完成登录:
  109 +{qr_login_url}
113 ``` 110 ```
114 111
115 -图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。 112 +> **展示规范(必须全部遵守)**:
  113 +> 1. 展示二维码图片(`qrcode_image_url`)。
  114 +> 2. 如果输出含 `qr_login_url`,**必须**同时展示该链接并提示用户"也可以在手机浏览器中直接访问此链接完成登录"。此链接是小红书官方登录地址(`xiaohongshu.com` 域名),既方便用户直接点击,也增加对二维码的信任感。
  115 +> 3. **禁止**省略 `qr_login_url`,即使已展示了二维码图片。
116 116
117 -**第三步** — 等待登录完成(**单次调用,无需轮询**): 117 +图片内嵌在对话窗口,用户可以扫码或直接访问链接登录。
  118 +
  119 +**第二步** — 等待登录完成(**单次调用,无需轮询**):
118 120
119 ```bash 121 ```bash
120 python scripts/cli.py wait-login 122 python scripts/cli.py wait-login
121 ``` 123 ```
122 124
123 - 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。 125 - 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。
124 -- 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 126 +- 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 刷新二维码。
  127 +
  128 +> **二维码过期刷新**:如需单独刷新二维码(如超时后),可运行 `get-qrcode`,它仍作为独立命令保留。
125 129
126 #### 方式 B:手机验证码登录(无界面服务器,分两步) 130 #### 方式 B:手机验证码登录(无界面服务器,分两步)
127 131
@@ -140,7 +144,8 @@ python scripts/cli.py send-code --phone <用户确认的手机号> @@ -140,7 +144,8 @@ python scripts/cli.py send-code --phone <用户确认的手机号>
140 ``` 144 ```
141 - 自动填写手机号、勾选用户协议、点击"获取验证码"。 145 - 自动填写手机号、勾选用户协议、点击"获取验证码"。
142 - Chrome 页面保持打开,等待下一步。 146 - Chrome 页面保持打开,等待下一步。
143 -- 输出:`{"status": "code_sent", "message": "验证码已发送至 138****0000,请运行 verify-code --code <验证码>"}` 147 +- 正常输出:`{"status": "code_sent", "message": "..."}`
  148 +- **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`
144 149
145 **第二步** — 向用户询问验证码,然后提交登录: 150 **第二步** — 向用户询问验证码,然后提交登录:
146 151
@@ -154,6 +159,8 @@ python scripts/cli.py verify-code --code <用户提供的6位验证码> @@ -154,6 +159,8 @@ python scripts/cli.py verify-code --code <用户提供的6位验证码>
154 159
155 ### 清除 Cookies(切换账号/退出登录) 160 ### 清除 Cookies(切换账号/退出登录)
156 161
  162 +> `delete-cookies` 命令内部自动完成两步:先通过页面 UI 点击「更多」→「退出登录」,再删除本地 cookies 文件。只需执行一条命令即可。
  163 +
157 ```bash 164 ```bash
158 python scripts/cli.py delete-cookies 165 python scripts/cli.py delete-cookies
159 python scripts/cli.py --account work delete-cookies # 指定账号 166 python scripts/cli.py --account work delete-cookies # 指定账号