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`,在此类情况下自动降级为二维码登录。
Showing
4 changed files
with
338 additions
and
139 deletions
| @@ -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 | + |
| 110 | 107 | ||
| 111 | -``` | ||
| 112 | - | 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 # 指定账号 |
-
Please register or login to post a comment