From 2641ba6f879f9bc6f890e5b5b140cfc9f23deef8 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Mon, 2 Nov 2026 10:35:56 +0800 Subject: [PATCH] feat(auth): print a scannable ASCII QR in the terminal WSL and --no-browser never showed the PNG, and placeholder Chinese cookies crashed urllib latin-1 headers before the code was issued. --- README.md | 6 +- reaction_board/bilibili/auth.py | 20 ++++- reaction_board/bilibili/auth_flow.py | 53 ++++++++++++- reaction_board/cli.py | 2 +- tests/test_auth.py | 113 ++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9453037..eb872d4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ | 功能 | 状态 | 说明 | |---|---|---| | 收藏夹排序 | 可用 | 同一 UP 连续,组内按发布时间倒序,组间按该 UP 最早视频发布时间倒序。唯一使用写鉴权的功能。 | -| 扫码授权与 Cookie 续期 | 可用 | 本地生成二维码;更新身份 Cookie 时保留设备指纹。 | +| 扫码授权与 Cookie 续期 | 可用 | 终端打印 ASCII 二维码并写出 PNG;更新身份 Cookie 时保留设备指纹。 | | 视频入库 | 可用 | 搜索候选经 Telegram 确认入库;私聊发送视频链接、BV 号、av 号或 `b23.tv` 短链手动入库。只写本地库。 | | 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 | | 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 | @@ -283,7 +283,7 @@ docker compose up -d --build - 服务器配置需使用 `web.host = 0.0.0.0` 并设置 `web.auth_token`,否则服务拒绝启动; - `storage.database_path` 使用 `/data/app.db`,`storage.backup_dir` 使用 `/data/backup`; - 首次部署可先在本地执行 `uv run python app.py auth --account read` 扫码,再把生成好的 `config.json` 放到服务器; -- 也可在容器内扫码:`docker compose -f compose.example.yml run --rm board python app.py auth --account read --no-browser`,二维码会打印在临时文件路径,可用 `docker cp` 取出; +- 也可在容器内扫码:`docker compose -f compose.example.yml run --rm board python app.py auth --account read --no-browser`,终端会打印 ASCII 二维码;PNG 仍写在临时文件路径,可用 `docker cp` 取出; - 镜像包含 `tools/`,可直接在容器内执行 `python tools/import_fans_csv.py ...` 回填粉丝历史; - 数据卷包含数据库、备份与预览库,建议定期备份 `/data`。 @@ -381,7 +381,7 @@ uv run python app.py auth --account write uv run python app.py auth-check --account all ``` -授权流程在本地生成二维码图片并等待手机客户端扫码确认。成功后: +授权流程会在终端打印 UTF-8 半块字符二维码(TTY 下白底黑字,方便深色终端扫码),同时写出 PNG,并等待手机客户端扫码确认。`--no-browser` 只跳过打开图片,仍会打印终端二维码。成功后: - 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段; - 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹; diff --git a/reaction_board/bilibili/auth.py b/reaction_board/bilibili/auth.py index a327e80..d4e4368 100644 --- a/reaction_board/bilibili/auth.py +++ b/reaction_board/bilibili/auth.py @@ -45,12 +45,30 @@ def strip_identity(cookie: str) -> str: return "; ".join(kept) +def _is_latin1(value: str) -> bool: + try: + value.encode("latin-1") + except UnicodeEncodeError: + return False + return True + + +def cookie_header(cookie: str, *, auth: bool = False) -> str: + """生成可放入 HTTP 头的 Cookie。占位中文、缺 ``=`` 的片段和非 latin-1 字段都会丢掉。""" + source = cookie if auth else strip_identity(cookie) + return "; ".join( + f"{key}={value}" + for key, value in parse_cookie(source) + if _is_latin1(key) and _is_latin1(value) + ) + + def make_headers(cfg: dict, auth: bool = False, *, referer: str | None = None) -> dict[str, str]: """构造与 ``data/raw-request.txt`` 对齐的 B 站 Web 请求头。""" return { "accept": "*/*", "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", - "cookie": cfg["cookie"] if auth else strip_identity(cfg["cookie"]), + "cookie": cookie_header(str(cfg.get("cookie") or ""), auth=auth), "dnt": "1", "origin": "https://space.bilibili.com", "priority": "u=1, i", diff --git a/reaction_board/bilibili/auth_flow.py b/reaction_board/bilibili/auth_flow.py index abee8c2..83f88ac 100644 --- a/reaction_board/bilibili/auth_flow.py +++ b/reaction_board/bilibili/auth_flow.py @@ -40,6 +40,47 @@ import qrcodegen # noqa: E402 BASE = "https://api.bilibili.com" PASSPORT = "https://passport.bilibili.com" +_QR_BLOCKS = { + (False, False): " ", + (True, False): "▀", + (False, True): "▄", + (True, True): "█", +} +_QR_ASCII_STYLE = "\033[48;5;231m\033[38;5;16m" +_QR_ASCII_RESET = "\033[0m" + + +def render_qr_ascii(text: str, *, border: int = 2) -> str: + """把登录链接编成终端半块字符二维码(两行模块合成一行)。""" + if border < 0: + raise ValueError("border 必须 >= 0") + qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM) + size = qr.get_size() + dim = size + border * 2 + + def dark(x: int, y: int) -> bool: + return qr.get_module(x - border, y - border) + + lines: list[str] = [] + for y in range(0, dim, 2): + row: list[str] = [] + bottom_y = y + 1 + for x in range(dim): + top = dark(x, y) + bottom = dark(x, bottom_y) if bottom_y < dim else False + row.append(_QR_BLOCKS[(top, bottom)]) + lines.append("".join(row)) + return "\n".join(lines) + + +def format_qr_ascii(text: str, *, border: int = 2, color: bool | None = None) -> str: + """终端二维码;TTY 下加白底黑字,方便深色终端扫码。""" + body = render_qr_ascii(text, border=border) + if color is None: + color = sys.stdout.isatty() + if not color: + return body + return "\n".join(f"{_QR_ASCII_STYLE}{line}{_QR_ASCII_RESET}" for line in body.split("\n")) def write_qr_png(text: str, path: str | Path, scale: int = 8, border: int = 4) -> Path: @@ -200,7 +241,7 @@ class AuthService: def run(self, *, timeout: int, no_browser: bool) -> int: raw = self._load_raw() credentials = raw["bilibili"][self.account] - print(f"[auth] 为 {self.account} 账号申请扫码登录 ...") + print(f"[auth] 为 {self.account} 账号申请扫码登录 ...", flush=True) generated, _ = self._request( credentials, f"{PASSPORT}/x/passport-login/web/qrcode/generate?source=main-fe-header", auth=False ) @@ -209,14 +250,18 @@ class AuthService: if generated.get("code") != 0 or not key or not login_url: print(f"[auth] 申请二维码失败: code={generated.get('code')} {generated.get('message')}") return 2 + login_url = str(login_url) qr_path = Path(tempfile.gettempdir()) / f"bili-fav-{self.account}-{key[:8]}.png" - write_qr_png(str(login_url), qr_path) - print(f"[auth] 二维码已生成: {qr_path}") + write_qr_png(login_url, qr_path) + print(f"[auth] 二维码已生成: {qr_path}", flush=True) + print("[auth] 请使用哔哩哔哩 App 扫描下方二维码", flush=True) + print(format_qr_ascii(login_url), flush=True) + print(f"[auth] 登录链接: {login_url}", flush=True) if not no_browser: try: open_path(qr_path) except Exception as exc: # noqa: BLE001 - print(f"[auth] 打开图片失败: {exc},请手动打开二维码") + print(f"[auth] 打开图片失败: {exc},请手动扫描终端二维码") deadline, last = time.time() + timeout, None while time.time() < deadline: try: diff --git a/reaction_board/cli.py b/reaction_board/cli.py index 2e6372e..9d1761b 100644 --- a/reaction_board/cli.py +++ b/reaction_board/cli.py @@ -99,7 +99,7 @@ def _build_parser() -> argparse.ArgumentParser: auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie") auth.add_argument("--account", choices=("read", "write"), default="read", help="要授权的账号角色(默认 read)") - auth.add_argument("--no-browser", action="store_true", help="不自动打开二维码图片") + auth.add_argument("--no-browser", action="store_true", help="不自动打开二维码图片(仍在终端打印 ASCII 二维码)") auth.add_argument("--timeout", type=int, default=180, metavar="SEC", help="等待扫码的超时时间(默认 180 秒)") auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态") diff --git a/tests/test_auth.py b/tests/test_auth.py index 6af0170..38cf807 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- """双账号 auth 纯函数与写回隔离测试(不联网): uv run python tests/test_auth.py""" +import io import json import os import sys import tempfile +from contextlib import redirect_stdout from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.stdout.reconfigure(encoding="utf-8") from reaction_board.bilibili.auth import ( # noqa: E402 + cookie_header, cookie_value, cookies_from_set_cookie, make_headers, @@ -18,7 +21,14 @@ from reaction_board.bilibili.auth import ( # noqa: E402 parse_cookie, sessdata_expiry, ) -from reaction_board.bilibili.auth_flow import AuthService, write_qr_png # noqa: E402 +from reaction_board.bilibili.auth_flow import ( # noqa: E402 + AuthService, + _QR_BLOCKS, + format_qr_ascii, + render_qr_ascii, + write_qr_png, +) +import qrcodegen # noqa: E402 READ_OLD = "buvid3=READ-B3; {{SECRET_i5bghhpt}}readcsrf; DedeUserID=11; SESSDATA=old%2C1700000000%2Ca" WRITE_OLD = "buvid3=WRITE-B3; bili_jct=writecsrf; DedeUserID=22; SESSDATA=old%2C1700000000%2Cb" @@ -79,6 +89,83 @@ def test_qr_png(): assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") +def test_render_qr_ascii_roundtrip_and_quiet_zone(): + text = "https://passport.bilibili.com/h5/cloud/login?qrcode_key=abc" + for border in (0, 1, 2, 4): + art = render_qr_ascii(text, border=border) + qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM) + size = qr.get_size() + dim = size + border * 2 + lines = art.split("\n") + assert len(lines) == (dim + 1) // 2 + assert all(len(line) == dim for line in lines) + inverse = {glyph: pair for pair, glyph in _QR_BLOCKS.items()} + recovered = [] + for row, line in enumerate(lines): + tops, bottoms = [], [] + for glyph in line: + top, bottom = inverse[glyph] + tops.append(top) + bottoms.append(bottom) + recovered.append(tops) + if row * 2 + 1 < dim: + recovered.append(bottoms) + assert len(recovered) == dim + for y in range(dim): + for x in range(dim): + expected = qr.get_module(x - border, y - border) + assert recovered[y][x] is expected + if border >= 1: + assert all(line[0] == " " and line[-1] == " " for line in lines) + if border >= 2: + assert set(lines[0]) == {" "} + + try: + render_qr_ascii(text, border=-1) + except ValueError as exc: + assert "border" in str(exc) + else: + raise AssertionError("负 border 应报错") + + colored = format_qr_ascii(text, color=True) + plain = format_qr_ascii(text, color=False) + assert plain == render_qr_ascii(text) + assert "\033[48;5;231m" in colored + assert "\033[0m" in colored + assert plain.split("\n")[0] in colored + + +def test_run_prints_ascii_qr_without_network(): + login_url = "https://passport.bilibili.com/h5/cloud/login?qrcode_key=unit-test" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.json" + path.write_text(json.dumps(raw_config(), ensure_ascii=False), encoding="utf-8") + service = AuthService(path, account="read") + + def fake_request(credentials, url, *, auth): + if "qrcode/generate" in url: + return { + "code": 0, + "message": "OK", + "data": {"url": login_url, "qrcode_key": "abcdefghijklmnop"}, + }, [] + if "qrcode/poll" in url: + return {"code": 0, "data": {"code": 86038, "message": "二维码已过期"}}, [] + raise AssertionError(url) + + service._request = fake_request # type: ignore[method-assign] + buf = io.StringIO() + with redirect_stdout(buf): + assert service.run(timeout=9, no_browser=True) == 3 + output = buf.getvalue() + ascii_qr = render_qr_ascii(login_url) + assert ascii_qr in output + assert "请使用哔哩哔哩 App 扫描下方二维码" in output + assert login_url in output + assert "二维码已过期" in output + assert "bili-fav-read-abcdefgh.png" in output + + def test_make_headers_matches_raw_request(): headers = make_headers({"cookie": "SESSDATA=x; buvid3=y"}, auth=False) assert headers["origin"] == "https://space.bilibili.com" @@ -96,14 +183,36 @@ def test_make_headers_matches_raw_request(): {"cookie": ""}, referer="https://space.bilibili.com/1/favlist?fid=2" ) assert custom["referer"] == "https://space.bilibili.com/1/favlist?fid=2" + assert custom["cookie"] == "" + + +def test_make_headers_drops_placeholder_and_non_latin1_cookies(): + placeholder = "只读账号的完整 Cookie(需含 SESSDATA、bili_jct、DedeUserID 等)" + headers = make_headers({"cookie": placeholder}, auth=False) + for value in headers.values(): + value.encode("latin-1") + assert headers["cookie"] == "" + assert cookie_header(placeholder, auth=True) == "" + + mixed = make_headers( + {"cookie": "buvid3=abc; 写入账号的完整 Cookie; SESSDATA=x; note=中文"}, + auth=True, + ) + mixed["cookie"].encode("latin-1") + assert mixed["cookie"] == "buvid3=abc; SESSDATA=x" + missing = make_headers({}, auth=False) + assert missing["cookie"] == "" def main(): test_cookie_helpers() test_finish_updates_only_selected_account_and_rolls_back() test_qr_png() + test_render_qr_ascii_roundtrip_and_quiet_zone() + test_run_prints_ascii_qr_without_network() test_make_headers_matches_raw_request() - print("OK: 双账号 auth Cookie 合并、写回隔离与回滚测试全部通过") + test_make_headers_drops_placeholder_and_non_latin1_cookies() + print("OK: 双账号 auth Cookie 合并、写回隔离、终端二维码与回滚测试全部通过") if __name__ == "__main__":