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 内嵌显示
Showing
4 changed files
with
233 additions
and
35 deletions
| @@ -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)") |
| @@ -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 | + | ||
| 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 |
-
Please register or login to post a comment