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:
def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None:
"""频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。"""
from xhs.login import fetch_qrcode, save_qrcode_to_file
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
from xhs.urls import EXPLORE_URL
# 刷新页面使登录弹窗回到默认的二维码 tab
page.navigate(EXPLORE_URL)
page.wait_for_load()
png_bytes, already = fetch_qrcode(page)
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
browser.close()
_output({"logged_in": True, "message": "已登录"})
return
qrcode_path = save_qrcode_to_file(png_bytes)
import base64 as _b64
qrcode_data_url = (
"data:image/png;base64,"
+ _b64.b64encode(png_bytes).decode()
)
image_url, login_url = make_qrcode_url(png_bytes)
_open_file_if_display(qrcode_path)
_save_login_tab(page.target_id, args.port)
_clear_session_tab(args.port)
browser.close()
_output({
result: dict = {
"logged_in": False,
"login_method": "qrcode",
"qrcode_path": qrcode_path,
"qrcode_data_url": qrcode_data_url,
"qrcode_image_url": image_url,
"message": (
"验证码发送受限,已切换为二维码登录,请扫码。"
"扫码后运行 wait-login 等待登录结果。"
),
}, exit_code=1)
}
if login_url:
result["qr_login_url"] = login_url
_output(result, exit_code=1)
# ========== 子命令实现 ==========
def cmd_check_login(args: argparse.Namespace) -> None:
"""检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。"""
from xhs.login import check_login_status, fetch_qrcode, save_qrcode_to_file
"""检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。
直接调 fetch_qrcode 一步完成:导航 + 登录检查 + 二维码获取,
不再经过 check_login_status 避免重复导航和等待。
"""
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
browser, page = _connect(args)
try:
logged_in = check_login_status(page)
if logged_in:
_output({"logged_in": True}, exit_code=0)
return
# 未登录——当前页面已在登录弹窗,复用页面直接获取二维码
png_bytes, already = fetch_qrcode(page)
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
_output({"logged_in": True}, exit_code=0)
return
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
# 生成 data URL,AI 可直接内嵌到 markdown 图片
import base64 as _b64
qrcode_data_url = "data:image/png;base64," + _b64.b64encode(png_bytes).decode()
# 记录 login tab + 清除 session tab(与 cmd_get_qrcode 一致)
# 记录 login tab + 清除 session tab
_save_login_tab(page.target_id, args.port)
_clear_session_tab(args.port)
# CLI 终端有桌面时自动打开二维码图片
_open_file_if_display(qrcode_path)
from chrome_launcher import has_display
result: dict = {
"logged_in": False,
"qrcode_path": qrcode_path,
"qrcode_image_url": image_url,
}
if login_url:
result["qr_login_url"] = login_url
if has_display():
_output({
"logged_in": False,
"login_method": "qrcode",
"qrcode_path": qrcode_path,
"qrcode_data_url": qrcode_data_url,
"hint": "未登录,二维码已自动生成。扫码后运行 wait-login 等待登录结果",
}, exit_code=1)
result["login_method"] = "qrcode"
result["hint"] = (
"未登录,二维码已自动生成。"
"扫码后运行 wait-login 等待登录结果"
)
else:
_output({
"logged_in": False,
"login_method": "both",
"qrcode_path": qrcode_path,
"qrcode_data_url": qrcode_data_url,
"hint": (
"未登录,二维码已自动生成。"
"方式A: 直接扫码 + wait-login;"
"方式B: send-code --phone <手机号> + verify-code(手机验证码)"
),
}, exit_code=1)
result["login_method"] = "both"
result["hint"] = (
"未登录,二维码已自动生成。"
"方式A: 直接扫码 + wait-login;"
"方式B: send-code --phone <手机号>"
" + verify-code(手机验证码)"
)
_output(result, exit_code=1)
finally:
# 只断开 CDP 连接,不关闭 tab——保留登录页面
browser.close()
... ... @@ -357,7 +359,7 @@ def cmd_login(args: argparse.Namespace) -> None:
browser, page = _connect(args)
try:
png_bytes, already = fetch_qrcode(page)
png_bytes, _b64, already = fetch_qrcode(page)
if already:
_output({"logged_in": True, "message": "已登录"})
return
... ... @@ -366,7 +368,7 @@ def cmd_login(args: argparse.Namespace) -> None:
_open_file_if_display(qrcode_path)
print(
json.dumps(
{"qrcode_path": qrcode_path, "message": "请扫码登录,二维码已保存到文件"},
{"qrcode_path": qrcode_path, "message": "请扫码登录"},
ensure_ascii=False,
)
)
... ... @@ -457,11 +459,15 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境)
也会显示二维码,用户可选择扫任意一个。
"""
from xhs.login import fetch_qrcode, save_qrcode_to_file
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
browser, page = _connect(args)
png_bytes, already = fetch_qrcode(page)
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
browser.close_page(page)
browser.close()
... ... @@ -469,26 +475,26 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None:
return
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
# 生成 data URL,AI 可直接内嵌到 markdown 图片
import base64 as _b64
qrcode_data_url = "data:image/png;base64," + _b64.b64encode(png_bytes).decode()
# CLI 终端有桌面时自动打开二维码图片
_open_file_if_display(qrcode_path)
# 记录 login tab,供 wait-login 精确 reconnect
_save_login_tab(page.target_id, args.port)
# 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
# 清除 session tab 引用——隔离登录表单,防止其他命令复用
_clear_session_tab(args.port)
# 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码
# 只断开 CDP 连接,不关闭 tab——QR 会话保持
browser.close()
_output({
result: dict = {
"qrcode_path": qrcode_path,
"qrcode_data_url": qrcode_data_url,
"message": "二维码已生成,请扫码登录。扫码后运行 wait-login 等待登录结果。",
})
"qrcode_image_url": image_url,
"message": "二维码已生成,请扫码登录。"
"扫码后运行 wait-login 等待登录结果。",
}
if login_url:
result["qr_login_url"] = login_url
_output(result)
def cmd_wait_login(args: argparse.Namespace) -> None:
... ...
... ... @@ -10,7 +10,6 @@ import time
_QR_DIR = os.path.join(tempfile.gettempdir(), "xhs")
_QR_FILE = os.path.join(_QR_DIR, "login_qrcode.png")
_QR_BORDER = 16 # 截图时在元素四周留白的像素数
from .cdp import Page
from .errors import RateLimitError
... ... @@ -50,18 +49,6 @@ def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None:
raise RateLimitError()
def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:
"""等待认证 UI 出现,替代固定延迟。
轮询直到登录状态指示器或登录容器出现为止,避免无谓等待。
超时后静默返回,由调用方自行处理元素不存在的情况。
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if page.has_element(LOGIN_STATUS) or page.has_element(LOGIN_CONTAINER):
return
time.sleep(0.2)
def get_current_user_nickname(page: Page) -> str:
"""获取当前登录用户的真实昵称,失败时返回空字符串(best-effort)。
... ... @@ -71,8 +58,7 @@ def get_current_user_nickname(page: Page) -> str:
try:
page.navigate(EXPLORE_URL)
page.wait_for_load()
_wait_for_auth_ui(page)
if not page.has_element(LOGIN_STATUS):
if not check_login_status(page):
return ""
# 从导航栏"我"的链接取个人主页 URL(含 /user/profile/<user_id>)
... ... @@ -103,20 +89,32 @@ def check_login_status(page: Page) -> bool:
Returns:
True 已登录,False 未登录。
"""
page.navigate(EXPLORE_URL)
page.wait_for_load()
_wait_for_auth_ui(page)
# 如果当前页面已在 explore,跳过重复导航
current_url = page.evaluate("location.href") or ""
if "explore" not in current_url:
page.navigate(EXPLORE_URL)
page.wait_for_load()
return page.has_element(LOGIN_STATUS)
# 直接等待登录状态或登录容器出现,替代 _wait_for_auth_ui
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
if page.has_element(LOGIN_STATUS):
return True
if page.has_element(LOGIN_CONTAINER):
return False
time.sleep(0.2)
return False
def fetch_qrcode(page: Page) -> tuple[bytes, bool]:
"""截取登录二维码图片(CDP 元素截图)。
def fetch_qrcode(page: Page) -> tuple[bytes, str, bool]:
"""获取登录二维码图片。
直接读取 img.src(data:image/png;base64,...),跳过 Canvas 绘制。
Returns:
(png_bytes, already_logged_in)
- 如果已登录,返回 (b"", True)
- 如果未登录,返回 (png_bytes, False)
(png_bytes, b64_str, already_logged_in)
- 如果已登录,返回 (b"", "", True)
- 如果未登录,返回 (png_bytes, b64_str, False)
"""
# 如果当前页面已在 explore(如 check-login 刚导航过),跳过重复导航
current_url = page.evaluate("location.href") or ""
... ... @@ -126,33 +124,95 @@ def fetch_qrcode(page: Page) -> tuple[bytes, bool]:
# 快速检查是否已登录,避免无谓等待二维码
if page.has_element(LOGIN_STATUS):
return b"", True
return b"", "", True
# 直接等待二维码元素出现,合并了 _wait_for_auth_ui 的逻辑
page.wait_for_element(QRCODE_IMG, timeout=15.0)
b64 = page.evaluate(
f"""
(() => {{
const img = document.querySelector({json.dumps(QRCODE_IMG)});
if (!img) return null;
const p = {_QR_BORDER};
const c = document.createElement('canvas');
c.width = img.naturalWidth + p * 2;
c.height = img.naturalHeight + p * 2;
const ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, p, p);
return c.toDataURL('image/png').split(',')[1];
}})()
"""
# img.src 本身就是 data:image/png;base64,...,直接读取
src = page.evaluate(
f"document.querySelector({json.dumps(QRCODE_IMG)})?.src || ''"
)
if not b64:
raise RuntimeError("二维码 Canvas 导出失败")
if not src or "base64," not in src:
raise RuntimeError("二维码图片 src 读取失败")
b64_str = src.split("base64,", 1)[1]
import base64
png_bytes = base64.b64decode(b64)
png_bytes = base64.b64decode(b64_str)
return png_bytes, b64_str, False
return png_bytes, False
def _decode_qr_content(png_bytes: bytes) -> str | None:
"""通过 goqr.me read API 解码二维码内容。
Returns:
解码后的文本(通常是登录 URL),失败返回 None。
"""
import http.client
boundary = "----XhsQrBoundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file";'
f' filename="qr.png"\r\n'
f"Content-Type: image/png\r\n\r\n"
).encode() + png_bytes + f"\r\n--{boundary}--\r\n".encode()
try:
conn = http.client.HTTPSConnection(
"api.qrserver.com", timeout=5
)
conn.request(
"POST",
"/v1/read-qr-code/",
body=body,
headers={
"Content-Type": (
f"multipart/form-data; boundary={boundary}"
),
},
)
resp = conn.getresponse()
if resp.status != 200:
return None
result = json.loads(resp.read().decode())
data = result[0]["symbol"][0].get("data")
return data if data else None
except Exception:
logger.debug("goqr.me 解码失败,将使用 base64 fallback")
return None
def make_qrcode_url(
png_bytes: bytes,
) -> tuple[str, str | None]:
"""生成二维码展示 URL 和登录链接。
通过 goqr.me read API 解码 QR 内容,构造 API 图片 URL
(~270 字符)和小红书官方登录链接。
Returns:
(image_url, login_url)
- image_url: 可用于 markdown 图片的 URL
- login_url: 小红书官方登录链接(解码失败时为 None)
"""
import base64
import urllib.parse
qr_content = _decode_qr_content(png_bytes)
if qr_content:
image_url = (
"https://api.qrserver.com/v1/create-qr-code/"
"?size=300x300&data="
+ urllib.parse.quote(qr_content, safe="")
)
return image_url, qr_content
# fallback: base64 data URL
b64 = base64.b64encode(png_bytes).decode()
return "data:image/png;base64," + b64, None
def save_qrcode_to_file(png_bytes: bytes) -> str:
... ... @@ -186,8 +246,11 @@ def send_phone_code(page: Page, phone: str) -> bool:
Raises:
RuntimeError: 找不到登录表单或手机号输入框。
"""
page.navigate(EXPLORE_URL)
page.wait_for_load()
# 如果当前页面已在 explore,跳过重复导航
current_url = page.evaluate("location.href") or ""
if "explore" not in current_url:
page.navigate(EXPLORE_URL)
page.wait_for_load()
# 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待)
try:
... ...
... ... @@ -89,21 +89,26 @@ python scripts/cli.py check-login
输出解读:
- `"logged_in": true` → 已登录,可执行后续操作。
- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,走方式 A(二维码)。输出自动包含 `qrcode_data_url` 和 `qrcode_path`
- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,走方式 A(二维码)。输出自动包含 `qrcode_image_url` 和 `qrcode_path`
- `"logged_in": false` + `"login_method": "both"` → 无界面服务器,输出自动包含二维码,**询问用户选方式 A(二维码)或方式 B(手机验证码)**
### 第二步:根据输出选择登录方式
#### 方式 A:二维码登录(所有平台通用)
> `check-login` 未登录时会自动返回二维码(`qrcode_data_url` + `qrcode_path`),无需单独调 `get-qrcode`。
> `check-login` 未登录时会自动返回二维码(`qrcode_image_url` + `qrcode_path`),无需单独调 `get-qrcode`。
**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_data_url`,在回复中直接写出
**第一步** — 从 `check-login` 返回的 JSON 取 `qrcode_image_url`,在回复中展示
```
![小红书登录二维码]({qrcode_data_url})
请使用小红书 App 扫描以下二维码登录:
![小红书登录二维码]({qrcode_image_url})
```
> **展示规范**:如果输出含 `qr_login_url`(小红书官方登录链接),必须同时展示给用户,增加信任感:
> "二维码对应的小红书登录链接:{qr_login_url}"。
图片内嵌在对话窗口,用户用小红书 App 扫对话里的二维码。
**第二步** — 等待登录完成(**单次调用,无需轮询**):
... ... @@ -135,7 +140,7 @@ python scripts/cli.py send-code --phone <用户确认的手机号>
- 自动填写手机号、勾选用户协议、点击"获取验证码"。
- Chrome 页面保持打开,等待下一步。
- 正常输出:`{"status": "code_sent", "message": "..."}`
- **频率限制**:自动切换为二维码登录,输出含 `qrcode_data_url`。告知用户"验证码发送受限,已切换为二维码登录",展示二维码,然后运行 `wait-login`
- **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`
**第二步** — 向用户询问验证码,然后提交登录:
... ...