Angiin

perf: QR码登录优化 - goqr.me API替代base64 + 重复导航消除

- 用 goqr.me read API 解码 QR 内容,生成 API 图片 URL (~270字符)
  替代原始 base64 data URL (~6000字符),节省 95% tokens
- 返回 qr_login_url (小红书官方登录链接) 增加用户信任感
- fetch_qrcode 直接读取 img.src (data:image/png;base64,...)
  跳过 Canvas 绘制
- check_login_status / fetch_qrcode / send_phone_code 跳过重复导航
- 删除 _wait_for_auth_ui 死代码,内联等待逻辑
- cmd_check_login 直接调 fetch_qrcode 一步完成检查+获取二维码
- cmd_phone_login 补充 RateLimitError 捕获,降级为二维码登录
- 删除终端二维码打印逻辑 (print_qrcode_to_terminal)
- SKILL.md 更新字段名和展示规范
@@ -253,99 +253,101 @@ def _headless_fallback(port: int) -> None: @@ -253,99 +253,101 @@ def _headless_fallback(port: int) -> None:
253 253
254 def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None: 254 def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None:
255 """频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。""" 255 """频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。"""
256 - from xhs.login import fetch_qrcode, save_qrcode_to_file 256 + from xhs.login import (
  257 + fetch_qrcode,
  258 + make_qrcode_url,
  259 + save_qrcode_to_file,
  260 + )
257 from xhs.urls import EXPLORE_URL 261 from xhs.urls import EXPLORE_URL
258 262
259 # 刷新页面使登录弹窗回到默认的二维码 tab 263 # 刷新页面使登录弹窗回到默认的二维码 tab
260 page.navigate(EXPLORE_URL) 264 page.navigate(EXPLORE_URL)
261 page.wait_for_load() 265 page.wait_for_load()
262 266
263 - png_bytes, already = fetch_qrcode(page) 267 + png_bytes, _b64_orig, already = fetch_qrcode(page)
264 if already: 268 if already:
265 browser.close() 269 browser.close()
266 _output({"logged_in": True, "message": "已登录"}) 270 _output({"logged_in": True, "message": "已登录"})
267 return 271 return
268 272
269 qrcode_path = save_qrcode_to_file(png_bytes) 273 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 - ) 274 + image_url, login_url = make_qrcode_url(png_bytes)
276 275
277 _open_file_if_display(qrcode_path) 276 _open_file_if_display(qrcode_path)
278 277
279 _save_login_tab(page.target_id, args.port) 278 _save_login_tab(page.target_id, args.port)
280 _clear_session_tab(args.port) 279 _clear_session_tab(args.port)
281 browser.close() 280 browser.close()
282 - _output({ 281 + result: dict = {
283 "logged_in": False, 282 "logged_in": False,
284 "login_method": "qrcode", 283 "login_method": "qrcode",
285 "qrcode_path": qrcode_path, 284 "qrcode_path": qrcode_path,
286 - "qrcode_data_url": qrcode_data_url, 285 + "qrcode_image_url": image_url,
287 "message": ( 286 "message": (
288 "验证码发送受限,已切换为二维码登录,请扫码。" 287 "验证码发送受限,已切换为二维码登录,请扫码。"
289 "扫码后运行 wait-login 等待登录结果。" 288 "扫码后运行 wait-login 等待登录结果。"
290 ), 289 ),
291 - }, exit_code=1) 290 + }
  291 + if login_url:
  292 + result["qr_login_url"] = login_url
  293 + _output(result, exit_code=1)
292 294
293 295
294 # ========== 子命令实现 ========== 296 # ========== 子命令实现 ==========
295 297
296 298
297 def cmd_check_login(args: argparse.Namespace) -> None: 299 def cmd_check_login(args: argparse.Namespace) -> None:
298 - """检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。"""  
299 - from xhs.login import check_login_status, fetch_qrcode, save_qrcode_to_file 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 + )
300 310
301 browser, page = _connect(args) 311 browser, page = _connect(args)
302 try: 312 try:
303 - logged_in = check_login_status(page)  
304 - if logged_in:  
305 - _output({"logged_in": True}, exit_code=0)  
306 - return  
307 -  
308 - # 未登录——当前页面已在登录弹窗,复用页面直接获取二维码  
309 - png_bytes, already = fetch_qrcode(page) 313 + png_bytes, _b64_orig, already = fetch_qrcode(page)
310 if already: 314 if already:
311 _output({"logged_in": True}, exit_code=0) 315 _output({"logged_in": True}, exit_code=0)
312 return 316 return
313 317
314 qrcode_path = save_qrcode_to_file(png_bytes) 318 qrcode_path = save_qrcode_to_file(png_bytes)
  319 + image_url, login_url = make_qrcode_url(png_bytes)
315 320
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 + # 记录 login tab + 清除 session tab
321 _save_login_tab(page.target_id, args.port) 322 _save_login_tab(page.target_id, args.port)
322 _clear_session_tab(args.port) 323 _clear_session_tab(args.port)
323 324
324 - # CLI 终端有桌面时自动打开二维码图片  
325 _open_file_if_display(qrcode_path) 325 _open_file_if_display(qrcode_path)
326 326
327 from chrome_launcher import has_display 327 from chrome_launcher import has_display
328 328
329 - if has_display():  
330 - _output({ 329 + result: dict = {
331 "logged_in": False, 330 "logged_in": False,
332 - "login_method": "qrcode",  
333 "qrcode_path": qrcode_path, 331 "qrcode_path": qrcode_path,
334 - "qrcode_data_url": qrcode_data_url,  
335 - "hint": "未登录,二维码已自动生成。扫码后运行 wait-login 等待登录结果",  
336 - }, exit_code=1) 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 + )
337 else: 342 else:
338 - _output({  
339 - "logged_in": False,  
340 - "login_method": "both",  
341 - "qrcode_path": qrcode_path,  
342 - "qrcode_data_url": qrcode_data_url,  
343 - "hint": ( 343 + result["login_method"] = "both"
  344 + result["hint"] = (
344 "未登录,二维码已自动生成。" 345 "未登录,二维码已自动生成。"
345 "方式A: 直接扫码 + wait-login;" 346 "方式A: 直接扫码 + wait-login;"
346 - "方式B: send-code --phone <手机号> + verify-code(手机验证码)"  
347 - ),  
348 - }, exit_code=1) 347 + "方式B: send-code --phone <手机号>"
  348 + " + verify-code(手机验证码)"
  349 + )
  350 + _output(result, exit_code=1)
349 finally: 351 finally:
350 # 只断开 CDP 连接,不关闭 tab——保留登录页面 352 # 只断开 CDP 连接,不关闭 tab——保留登录页面
351 browser.close() 353 browser.close()
@@ -357,7 +359,7 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -357,7 +359,7 @@ def cmd_login(args: argparse.Namespace) -> None:
357 359
358 browser, page = _connect(args) 360 browser, page = _connect(args)
359 try: 361 try:
360 - png_bytes, already = fetch_qrcode(page) 362 + png_bytes, _b64, already = fetch_qrcode(page)
361 if already: 363 if already:
362 _output({"logged_in": True, "message": "已登录"}) 364 _output({"logged_in": True, "message": "已登录"})
363 return 365 return
@@ -366,7 +368,7 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -366,7 +368,7 @@ def cmd_login(args: argparse.Namespace) -> None:
366 _open_file_if_display(qrcode_path) 368 _open_file_if_display(qrcode_path)
367 print( 369 print(
368 json.dumps( 370 json.dumps(
369 - {"qrcode_path": qrcode_path, "message": "请扫码登录,二维码已保存到文件"}, 371 + {"qrcode_path": qrcode_path, "message": "请扫码登录"},
370 ensure_ascii=False, 372 ensure_ascii=False,
371 ) 373 )
372 ) 374 )
@@ -457,11 +459,15 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -457,11 +459,15 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
457 调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境) 459 调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境)
458 也会显示二维码,用户可选择扫任意一个。 460 也会显示二维码,用户可选择扫任意一个。
459 """ 461 """
460 - 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 + )
461 467
462 browser, page = _connect(args) 468 browser, page = _connect(args)
463 469
464 - png_bytes, already = fetch_qrcode(page) 470 + png_bytes, _b64_orig, already = fetch_qrcode(page)
465 if already: 471 if already:
466 browser.close_page(page) 472 browser.close_page(page)
467 browser.close() 473 browser.close()
@@ -469,26 +475,26 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: @@ -469,26 +475,26 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
469 return 475 return
470 476
471 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)
472 479
473 - # 生成 data URL,AI 可直接内嵌到 markdown 图片  
474 - import base64 as _b64  
475 - qrcode_data_url = "data:image/png;base64," + _b64.b64encode(png_bytes).decode()  
476 -  
477 - # CLI 终端有桌面时自动打开二维码图片  
478 _open_file_if_display(qrcode_path) 480 _open_file_if_display(qrcode_path)
479 481
480 # 记录 login tab,供 wait-login 精确 reconnect 482 # 记录 login tab,供 wait-login 精确 reconnect
481 _save_login_tab(page.target_id, args.port) 483 _save_login_tab(page.target_id, args.port)
482 - # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab 484 + # 清除 session tab 引用——隔离登录表单,防止其他命令复用
483 _clear_session_tab(args.port) 485 _clear_session_tab(args.port)
484 486
485 - # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码 487 + # 只断开 CDP 连接,不关闭 tab——QR 会话保持
486 browser.close() 488 browser.close()
487 - _output({ 489 + result: dict = {
488 "qrcode_path": qrcode_path, 490 "qrcode_path": qrcode_path,
489 - "qrcode_data_url": qrcode_data_url,  
490 - "message": "二维码已生成,请扫码登录。扫码后运行 wait-login 等待登录结果。",  
491 - }) 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)
492 498
493 499
494 def cmd_wait_login(args: argparse.Namespace) -> None: 500 def cmd_wait_login(args: argparse.Namespace) -> None:
@@ -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,20 +89,32 @@ def check_login_status(page: Page) -> bool: @@ -103,20 +89,32 @@ def check_login_status(page: Page) -> bool:
103 Returns: 89 Returns:
104 True 已登录,False 未登录。 90 True 已登录,False 未登录。
105 """ 91 """
  92 + # 如果当前页面已在 explore,跳过重复导航
  93 + current_url = page.evaluate("location.href") or ""
  94 + if "explore" not in current_url:
106 page.navigate(EXPLORE_URL) 95 page.navigate(EXPLORE_URL)
107 page.wait_for_load() 96 page.wait_for_load()
108 - _wait_for_auth_ui(page)  
109 97
110 - return page.has_element(LOGIN_STATUS) 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
111 107
112 108
113 -def fetch_qrcode(page: Page) -> tuple[bytes, bool]:  
114 - """截取登录二维码图片(CDP 元素截图)。 109 +def fetch_qrcode(page: Page) -> tuple[bytes, str, bool]:
  110 + """获取登录二维码图片。
  111 +
  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 # 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航 119 # 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航
122 current_url = page.evaluate("location.href") or "" 120 current_url = page.evaluate("location.href") or ""
@@ -126,33 +124,95 @@ def fetch_qrcode(page: Page) -> tuple[bytes, bool]: @@ -126,33 +124,95 @@ def fetch_qrcode(page: Page) -> tuple[bytes, bool]:
126 124
127 # 快速检查是否已登录,避免无谓等待二维码 125 # 快速检查是否已登录,避免无谓等待二维码
128 if page.has_element(LOGIN_STATUS): 126 if page.has_element(LOGIN_STATUS):
129 - return b"", True 127 + return b"", "", True
130 128
131 # 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑 129 # 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑
132 page.wait_for_element(QRCODE_IMG, timeout=15.0) 130 page.wait_for_element(QRCODE_IMG, timeout=15.0)
133 - b64 = page.evaluate(  
134 - f"""  
135 - (() => {{  
136 - const img = document.querySelector({json.dumps(QRCODE_IMG)});  
137 - if (!img) return null;  
138 - const p = {_QR_BORDER};  
139 - const c = document.createElement('canvas');  
140 - c.width = img.naturalWidth + p * 2;  
141 - c.height = img.naturalHeight + p * 2;  
142 - const ctx = c.getContext('2d');  
143 - ctx.fillStyle = '#ffffff';  
144 - ctx.fillRect(0, 0, c.width, c.height);  
145 - ctx.drawImage(img, p, p);  
146 - return c.toDataURL('image/png').split(',')[1];  
147 - }})() 131 +
  132 + # img.src 本身就是 data:image/png;base64,...,直接读取
  133 + src = page.evaluate(
  134 + f"document.querySelector({json.dumps(QRCODE_IMG)})?.src || ''"
  135 + )
  136 + if not src or "base64," not in src:
  137 + raise RuntimeError("二维码图片 src 读取失败")
  138 +
  139 + b64_str = src.split("base64,", 1)[1]
  140 +
  141 + import base64
  142 + png_bytes = base64.b64decode(b64_str)
  143 +
  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。
148 """ 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 + },
149 ) 176 )
150 - if not b64:  
151 - raise RuntimeError("二维码 Canvas 导出失败") 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 + """
152 import base64 201 import base64
153 - png_bytes = base64.b64decode(b64) 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
154 212
155 - return png_bytes, False 213 + # fallback: base64 data URL
  214 + b64 = base64.b64encode(png_bytes).decode()
  215 + return "data:image/png;base64," + b64, None
156 216
157 217
158 def save_qrcode_to_file(png_bytes: bytes) -> str: 218 def save_qrcode_to_file(png_bytes: bytes) -> str:
@@ -186,6 +246,9 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -186,6 +246,9 @@ def send_phone_code(page: Page, phone: str) -> bool:
186 Raises: 246 Raises:
187 RuntimeError: 找不到登录表单或手机号输入框。 247 RuntimeError: 找不到登录表单或手机号输入框。
188 """ 248 """
  249 + # 如果当前页面已在 explore,跳过重复导航
  250 + current_url = page.evaluate("location.href") or ""
  251 + if "explore" not in current_url:
189 page.navigate(EXPLORE_URL) 252 page.navigate(EXPLORE_URL)
190 page.wait_for_load() 253 page.wait_for_load()
191 254
@@ -89,21 +89,26 @@ python scripts/cli.py check-login @@ -89,21 +89,26 @@ 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(二维码)。输出自动包含 `qrcode_data_url` 和 `qrcode_path` 92 +- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,走方式 A(二维码)。输出自动包含 `qrcode_image_url` 和 `qrcode_path`
93 - `"logged_in": false` + `"login_method": "both"` → 无界面服务器,输出自动包含二维码,**询问用户选方式 A(二维码)或方式 B(手机验证码)** 93 - `"logged_in": false` + `"login_method": "both"` → 无界面服务器,输出自动包含二维码,**询问用户选方式 A(二维码)或方式 B(手机验证码)**
94 94
95 ### 第二步:根据输出选择登录方式 95 ### 第二步:根据输出选择登录方式
96 96
97 #### 方式 A:二维码登录(所有平台通用) 97 #### 方式 A:二维码登录(所有平台通用)
98 98
99 -> `check-login` 未登录时会自动返回二维码(`qrcode_data_url` + `qrcode_path`),无需单独调 `get-qrcode`。 99 +> `check-login` 未登录时会自动返回二维码(`qrcode_image_url` + `qrcode_path`),无需单独调 `get-qrcode`。
100 100
101 -**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_data_url`,在回复中直接写出 101 +**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_image_url`,在回复中展示
102 102
103 ``` 103 ```
104 -![小红书登录二维码]({qrcode_data_url}) 104 +请使用小红书 App 扫描以下二维码登录:
  105 +
  106 +![小红书登录二维码]({qrcode_image_url})
105 ``` 107 ```
106 108
  109 +> **展示规范**:如果输出含 `qr_login_url`(小红书官方登录链接),必须同时展示给用户,增加信任感:
  110 +> "二维码对应的小红书登录链接:{qr_login_url}"。
  111 +
107 图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。 112 图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。
108 113
109 **第二步** — 等待登录完成(**单次调用,无需轮询**): 114 **第二步** — 等待登录完成(**单次调用,无需轮询**):
@@ -135,7 +140,7 @@ python scripts/cli.py send-code --phone <用户确认的手机号> @@ -135,7 +140,7 @@ python scripts/cli.py send-code --phone <用户确认的手机号>
135 - 自动填写手机号、勾选用户协议、点击"获取验证码"。 140 - 自动填写手机号、勾选用户协议、点击"获取验证码"。
136 - Chrome 页面保持打开,等待下一步。 141 - Chrome 页面保持打开,等待下一步。
137 - 正常输出:`{"status": "code_sent", "message": "..."}` 142 - 正常输出:`{"status": "code_sent", "message": "..."}`
138 -- **频率限制**:自动切换为二维码登录,输出含 `qrcode_data_url`。告知用户"验证码发送受限,已切换为二维码登录",展示二维码,然后运行 `wait-login` 143 +- **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`
139 144
140 **第二步** — 向用户询问验证码,然后提交登录: 145 **第二步** — 向用户询问验证码,然后提交登录:
141 146