Angiin

feat: 优化登录流程,新增二维码截图并内嵌显示

- 新增 get-qrcode 子命令:非阻塞获取二维码,立即返回路径
- save_qrcode_to_file 支持 data URL 和网络 URL 两种格式
- 输出 qrcode_data_url 供 Skill 直接内嵌 Markdown 图片
- 新增 _add_png_border:纯 stdlib 给 PNG 添加 16px 白色边框
  支持 Grayscale/RGB/Grayscale+Alpha/RGBA,全 filter 类型解码
- check-login 非 GUI 环境返回 login_method: both,支持二维码或手机验证码二选一
- xhs-auth SKILL.md:GUI 走 login(阻塞),无界面走 get-qrcode + data_url 内嵌显示
@@ -112,20 +112,29 @@ def cmd_check_login(args: argparse.Namespace) -> None: @@ -112,20 +112,29 @@ def cmd_check_login(args: argparse.Namespace) -> None:
112 _output({"logged_in": True}, exit_code=0) 112 _output({"logged_in": True}, exit_code=0)
113 else: 113 else:
114 from chrome_launcher import has_display 114 from chrome_launcher import has_display
115 - method = "qrcode" if has_display() else "phone"  
116 - hint = (  
117 - "请运行 login(二维码)完成登录"  
118 - if method == "qrcode"  
119 - else "请运行 send-code --phone <手机号>(手机验证码)完成登录"  
120 - )  
121 - _output({"logged_in": False, "login_method": method, "hint": hint}, exit_code=1) 115 + if has_display():
  116 + _output({
  117 + "logged_in": False,
  118 + "login_method": "qrcode",
  119 + "hint": "请运行 login,Chrome 窗口会弹出二维码,扫码后自动完成登录",
  120 + }, exit_code=1)
  121 + else:
  122 + # 无界面环境:二维码(扫对话窗口中的图片)和手机验证码均可
  123 + _output({
  124 + "logged_in": False,
  125 + "login_method": "both",
  126 + "hint": (
  127 + "方式A: get-qrcode(二维码将显示在对话窗口,扫码即可);"
  128 + "方式B: send-code --phone <手机号>(手机验证码)"
  129 + ),
  130 + }, exit_code=1)
122 finally: 131 finally:
123 browser.close_page(page) 132 browser.close_page(page)
124 browser.close() 133 browser.close()
125 134
126 135
127 def cmd_login(args: argparse.Namespace) -> None: 136 def cmd_login(args: argparse.Namespace) -> None:
128 - """获取登录二维码并等待扫码。""" 137 + """获取登录二维码并阻塞等待扫码(最多 120 秒)。"""
129 from xhs.login import fetch_qrcode, save_qrcode_to_file, wait_for_login 138 from xhs.login import fetch_qrcode, save_qrcode_to_file, wait_for_login
130 139
131 browser, page = _connect(args) 140 browser, page = _connect(args)
@@ -133,13 +142,14 @@ def cmd_login(args: argparse.Namespace) -> None: @@ -133,13 +142,14 @@ def cmd_login(args: argparse.Namespace) -> None:
133 src, already = fetch_qrcode(page) 142 src, already = fetch_qrcode(page)
134 if already: 143 if already:
135 _output({"logged_in": True, "message": "已登录"}) 144 _output({"logged_in": True, "message": "已登录"})
136 - else:  
137 - # 保存二维码到临时文件  
138 - qrcode_path = save_qrcode_to_file(src) 145 + return
  146 +
  147 + qrcode_path, qrcode_data_url = save_qrcode_to_file(src)
139 print( 148 print(
140 json.dumps( 149 json.dumps(
141 { 150 {
142 "qrcode_path": qrcode_path, 151 "qrcode_path": qrcode_path,
  152 + "qrcode_data_url": qrcode_data_url,
143 "message": "请扫码登录,二维码已保存到文件", 153 "message": "请扫码登录,二维码已保存到文件",
144 }, 154 },
145 ensure_ascii=False, 155 ensure_ascii=False,
@@ -199,6 +209,35 @@ def cmd_phone_login(args: argparse.Namespace) -> None: @@ -199,6 +209,35 @@ def cmd_phone_login(args: argparse.Namespace) -> None:
199 browser.close() 209 browser.close()
200 210
201 211
  212 +def cmd_get_qrcode(args: argparse.Namespace) -> None:
  213 + """获取登录二维码并立即返回(非阻塞)。
  214 +
  215 + 从登录弹窗的二维码 img 元素读取图片(data URL 或网络 URL),
  216 + 保存为本地 PNG 文件后立即退出。Chrome tab 保持打开,QR 会话继续有效。
  217 + 调用方收到 qrcode_path 后用 Read 工具将图片显示在对话窗口,再轮询 check-login。
  218 + """
  219 + from xhs.login import fetch_qrcode, save_qrcode_to_file
  220 +
  221 + browser, page = _connect(args)
  222 +
  223 + src, already = fetch_qrcode(page)
  224 + if already:
  225 + browser.close_page(page)
  226 + browser.close()
  227 + _output({"logged_in": True, "message": "已登录"})
  228 + return
  229 +
  230 + qrcode_path, qrcode_data_url = save_qrcode_to_file(src)
  231 +
  232 + # 只断开 CDP 连接,不关闭 tab——QR 会话保持,用户可继续扫码
  233 + browser.close()
  234 + _output({
  235 + "qrcode_path": qrcode_path,
  236 + "qrcode_data_url": qrcode_data_url,
  237 + "message": "二维码已生成,请扫码登录。扫码后运行 check-login 确认登录状态。",
  238 + })
  239 +
  240 +
202 def cmd_send_code(args: argparse.Namespace) -> None: 241 def cmd_send_code(args: argparse.Namespace) -> None:
203 """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。""" 242 """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。"""
204 from chrome_launcher import has_display, restart_chrome 243 from chrome_launcher import has_display, restart_chrome
@@ -681,9 +720,13 @@ def build_parser() -> argparse.ArgumentParser: @@ -681,9 +720,13 @@ def build_parser() -> argparse.ArgumentParser:
681 sub.set_defaults(func=cmd_check_login) 720 sub.set_defaults(func=cmd_check_login)
682 721
683 # login 722 # login
684 - sub = subparsers.add_parser("login", help="登录(扫码)") 723 + sub = subparsers.add_parser("login", help="登录(扫码,阻塞等待)")
685 sub.set_defaults(func=cmd_login) 724 sub.set_defaults(func=cmd_login)
686 725
  726 + # get-qrcode(非阻塞,截图后立即返回)
  727 + sub = subparsers.add_parser("get-qrcode", help="获取登录二维码截图并立即返回(非阻塞)")
  728 + sub.set_defaults(func=cmd_get_qrcode)
  729 +
687 # phone-login(单命令交互式) 730 # phone-login(单命令交互式)
688 sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)") 731 sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)")
689 sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)") 732 sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)")
@@ -515,6 +515,7 @@ class Page: @@ -515,6 +515,7 @@ class Page:
515 ) 515 )
516 516
517 517
  518 +
518 class Browser: 519 class Browser:
519 """Chrome 浏览器 CDP 控制器。""" 520 """Chrome 浏览器 CDP 控制器。"""
520 521
@@ -5,8 +5,126 @@ from __future__ import annotations @@ -5,8 +5,126 @@ from __future__ import annotations
5 import base64 5 import base64
6 import logging 6 import logging
7 import os 7 import os
  8 +import struct
8 import tempfile 9 import tempfile
9 import time 10 import time
  11 +import zlib
  12 +
  13 +_QR_DIR = os.path.join(tempfile.gettempdir(), "xhs")
  14 +_QR_FILE = os.path.join(_QR_DIR, "login_qrcode.png")
  15 +_QR_BORDER = 16 # 白边宽度(像素)
  16 +
  17 +_PNG_SIG = b"\x89PNG\r\n\x1a\n"
  18 +
  19 +
  20 +def _add_png_border(data: bytes, padding: int = _QR_BORDER) -> bytes:
  21 + """给 PNG 图片添加白色边框(纯 Python stdlib,不依赖 Pillow)。
  22 +
  23 + 支持 8-bit 深度的 Grayscale / RGB / Grayscale+Alpha / RGBA 四种色彩类型。
  24 + Indexed-color(color_type=3)暂不处理,原样返回。
  25 +
  26 + Args:
  27 + data: 原始 PNG 字节。
  28 + padding: 边框宽度(像素)。
  29 +
  30 + Returns:
  31 + 带白色边框的 PNG 字节。
  32 + """
  33 + if not data.startswith(_PNG_SIG):
  34 + return data
  35 +
  36 + # ── 解析 chunks ──────────────────────────────────────────────
  37 + def _read_chunks(buf: bytes) -> list[tuple[bytes, bytes]]:
  38 + result, pos = [], 8
  39 + while pos < len(buf):
  40 + (length,) = struct.unpack_from(">I", buf, pos)
  41 + ctype = buf[pos + 4 : pos + 8]
  42 + cdata = buf[pos + 8 : pos + 8 + length]
  43 + result.append((ctype, cdata))
  44 + pos += 12 + length
  45 + return result
  46 +
  47 + def _make_chunk(ctype: bytes, cdata: bytes) -> bytes:
  48 + crc = zlib.crc32(ctype + cdata) & 0xFFFFFFFF
  49 + return struct.pack(">I", len(cdata)) + ctype + cdata + struct.pack(">I", crc)
  50 +
  51 + chunks = _read_chunks(data)
  52 +
  53 + # ── IHDR ─────────────────────────────────────────────────────
  54 + ihdr = next(d for t, d in chunks if t == b"IHDR")
  55 + w, h = struct.unpack_from(">II", ihdr)
  56 + bit_depth, color_type = ihdr[8], ihdr[9]
  57 +
  58 + if bit_depth != 8 or color_type == 3:
  59 + return data # 不支持的格式,原样返回
  60 +
  61 + bpp = {0: 1, 2: 3, 4: 2, 6: 4}[color_type]
  62 + white = bytes([255] * bpp)
  63 +
  64 + # ── 解压 IDAT ────────────────────────────────────────────────
  65 + raw = zlib.decompress(b"".join(d for t, d in chunks if t == b"IDAT"))
  66 +
  67 + # ── 逐行解码 PNG filter,还原像素数据 ────────────────────────
  68 + stride = w * bpp
  69 +
  70 + def _paeth(a: int, b: int, c: int) -> int:
  71 + p = a + b - c
  72 + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
  73 + if pa <= pb and pa <= pc:
  74 + return a
  75 + return b if pb <= pc else c
  76 +
  77 + pixel_rows: list[bytes] = []
  78 + prior = bytearray(stride)
  79 + pos = 0
  80 + for _ in range(h):
  81 + f = raw[pos]
  82 + row = bytearray(raw[pos + 1 : pos + 1 + stride])
  83 + pos += 1 + stride
  84 + if f == 1: # Sub
  85 + for i in range(bpp, stride):
  86 + row[i] = (row[i] + row[i - bpp]) & 0xFF
  87 + elif f == 2: # Up
  88 + for i in range(stride):
  89 + row[i] = (row[i] + prior[i]) & 0xFF
  90 + elif f == 3: # Average
  91 + for i in range(stride):
  92 + a = row[i - bpp] if i >= bpp else 0
  93 + row[i] = (row[i] + (a + prior[i]) // 2) & 0xFF
  94 + elif f == 4: # Paeth
  95 + for i in range(stride):
  96 + a = row[i - bpp] if i >= bpp else 0
  97 + b = prior[i]
  98 + c = prior[i - bpp] if i >= bpp else 0
  99 + row[i] = (row[i] + _paeth(a, b, c)) & 0xFF
  100 + pixel_rows.append(bytes(row))
  101 + prior = row
  102 +
  103 + # ── 构建带边框的新图像(filter 0 = None,最简单)────────────
  104 + new_w, new_h = w + padding * 2, h + padding * 2
  105 + white_row = b"\x00" + white * new_w
  106 + pad_cols = white * padding
  107 +
  108 + new_raw = bytearray()
  109 + for _ in range(padding):
  110 + new_raw += white_row
  111 + for row in pixel_rows:
  112 + new_raw += b"\x00" + pad_cols + row + pad_cols
  113 + for _ in range(padding):
  114 + new_raw += white_row
  115 +
  116 + new_idat = zlib.compress(bytes(new_raw), 6)
  117 + new_ihdr = struct.pack(">II", new_w, new_h) + ihdr[8:]
  118 +
  119 + # ── 重建 PNG ─────────────────────────────────────────────────
  120 + out = bytearray(_PNG_SIG)
  121 + out += _make_chunk(b"IHDR", new_ihdr)
  122 + for ctype, cdata in chunks:
  123 + if ctype not in (b"IHDR", b"IDAT", b"IEND"):
  124 + out += _make_chunk(ctype, cdata)
  125 + out += _make_chunk(b"IDAT", new_idat)
  126 + out += _make_chunk(b"IEND", b"")
  127 + return bytes(out)
10 128
11 from .cdp import Page 129 from .cdp import Page
12 from .errors import RateLimitError 130 from .errors import RateLimitError
@@ -67,35 +185,41 @@ def fetch_qrcode(page: Page) -> tuple[str, bool]: @@ -67,35 +185,41 @@ def fetch_qrcode(page: Page) -> tuple[str, bool]:
67 return src, False 185 return src, False
68 186
69 187
70 -def save_qrcode_to_file(src: str) -> str:  
71 - """将二维码 data URL 保存为临时 PNG 文件。 188 +def save_qrcode_to_file(src: str) -> tuple[str, str]:
  189 + """将二维码图片保存为临时 PNG 文件,同时返回 data URL。
  190 +
  191 + 相当于浏览器"右键 → 另存为图片":从 img.src 取得图片字节后落盘。
72 192
73 Args: 193 Args:
74 - src: 二维码图片的 data URL(data:image/png;base64,...)或普通 URL。 194 + src: 二维码 img 元素的 src——data URL(data:image/...;base64,...)或网络 URL。
75 195
76 Returns: 196 Returns:
77 - 保存的文件绝对路径。 197 + (file_path, data_url)
  198 + - file_path: 保存的 PNG 文件绝对路径
  199 + - data_url: data:image/png;base64,... 格式,可直接嵌入 Markdown
78 """ 200 """
79 - prefix = "data:image/png;base64,"  
80 - if src.startswith(prefix):  
81 - img_data = base64.b64decode(src[len(prefix) :])  
82 - elif src.startswith("data:image/"):  
83 - # 处理其他 MIME 类型,如 data:image/jpeg;base64,... 201 + if src.startswith("data:image/"):
  202 + # data URL:直接解码
84 _, encoded = src.split(",", 1) 203 _, encoded = src.split(",", 1)
85 img_data = base64.b64decode(encoded) 204 img_data = base64.b64decode(encoded)
  205 + elif src.startswith("http://") or src.startswith("https://"):
  206 + # 网络 URL:下载(等同浏览器右键另存为)
  207 + import requests as _req
  208 + resp = _req.get(src, timeout=10)
  209 + resp.raise_for_status()
  210 + img_data = resp.content
86 else: 211 else:
87 - # 不是 data URL,无法保存  
88 - raise ValueError(f"不支持的二维码格式,需要 data URL: {src[:50]}...") 212 + raise ValueError(f"不支持的二维码格式: {src[:80]}")
89 213
90 - qr_dir = os.path.join(tempfile.gettempdir(), "xhs")  
91 - os.makedirs(qr_dir, exist_ok=True)  
92 - filepath = os.path.join(qr_dir, "login_qrcode.png") 214 + img_data = _add_png_border(img_data)
93 215
94 - with open(filepath, "wb") as f: 216 + os.makedirs(_QR_DIR, exist_ok=True)
  217 + with open(_QR_FILE, "wb") as f:
95 f.write(img_data) 218 f.write(img_data)
96 219
97 - logger.info("二维码已保存: %s", filepath)  
98 - return filepath 220 + data_url = "data:image/png;base64," + base64.b64encode(img_data).decode()
  221 + logger.info("二维码已保存: %s", _QR_FILE)
  222 + return _QR_FILE, data_url
99 223
100 224
101 def send_phone_code(page: Page, phone: str) -> bool: 225 def send_phone_code(page: Page, phone: str) -> bool:
@@ -33,20 +33,50 @@ python scripts/cli.py check-login @@ -33,20 +33,50 @@ python scripts/cli.py check-login
33 33
34 输出解读: 34 输出解读:
35 - `"logged_in": true` → 已登录,可执行后续操作。 35 - `"logged_in": true` → 已登录,可执行后续操作。
36 -- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,使用二维码登录。  
37 -- `"logged_in": false` + `"login_method": "phone"` → 无界面服务器,使用手机验证码登录。 36 +- `"logged_in": false` + `"login_method": "qrcode"` → 有界面环境,直接走二维码登录。
  37 +- `"logged_in": false` + `"login_method": "both"` → 无界面服务器,**询问用户选择方式 A(二维码)或方式 B(手机验证码)**
38 38
39 ### 第二步:根据 login_method 选择登录方式 39 ### 第二步:根据 login_method 选择登录方式
40 40
41 -#### 方式 A:二维码登录(有界面环境) 41 +#### 方式 A1:二维码登录 — 有界面(GUI)设备
42 42
43 ```bash 43 ```bash
44 python scripts/cli.py login 44 python scripts/cli.py login
45 ``` 45 ```
46 46
47 -1. 命令立即输出 `qrcode_path`(二维码图片路径),然后阻塞等待扫码(最多 120 秒)。  
48 -2. 提示用户用小红书 App 或微信扫码。  
49 -3. 扫码成功后输出 `"logged_in": true` 47 +- Chrome **有窗口**弹出,二维码直接显示在浏览器窗口中。
  48 +- 告知用户用小红书 App 扫屏幕上的二维码。
  49 +- 命令阻塞等待(最多 120 秒),扫码成功后输出 `{"logged_in": true}`
  50 +- 无需在对话窗口显示图片。
  51 +
  52 +#### 方式 A2:二维码登录 — 无界面(headless)服务器
  53 +
  54 +**第一步** — 获取二维码(非阻塞,立即返回):
  55 +
  56 +```bash
  57 +python scripts/cli.py get-qrcode
  58 +```
  59 +
  60 +- headless Chrome 加载登录页,从 `img` 元素读取二维码图片(相当于右键另存为)。
  61 +- 命令立即退出,Chrome tab 保持打开(QR 会话继续有效)。
  62 +- 输出:`{"qrcode_path": "...", "qrcode_data_url": "data:image/png;base64,...", "message": "..."}`
  63 +
  64 +**第二步** — 从 JSON 取 `qrcode_data_url`,在回复中直接写出:
  65 +
  66 +```
  67 +![小红书登录二维码]({qrcode_data_url})
  68 +```
  69 +
  70 +图片本身已含 16px 白色边框,内嵌在对话窗口,用户用小红书 App 扫对话里的二维码即可。
  71 +
  72 +**第三步** — 轮询登录状态(每 10 秒一次,最多 12 次):
  73 +
  74 +```bash
  75 +python scripts/cli.py check-login
  76 +```
  77 +
  78 +- `"logged_in": true` 则完成。
  79 +- 2 分钟内未登录,提示用户重新执行第一步。
50 80
51 #### 方式 B:手机验证码登录(无界面服务器,分两步) 81 #### 方式 B:手机验证码登录(无界面服务器,分两步)
52 82