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.
This commit is contained in:
kairee committed 2026-11-02 10:35:56 +08:00
1 parent 12732f6316
commit 2641ba6f87
5 files changed
+183 -11

No files matched your search

+3 -3
View File
@@ -15,7 +15,7 @@
| 功能 | 状态 | 说明 | | 功能 | 状态 | 说明 |
|---|---|---| |---|---|---|
| 收藏夹排序 | 可用 | 同一 UP 连续,组内按发布时间倒序,组间按该 UP 最早视频发布时间倒序。唯一使用写鉴权的功能。 | | 收藏夹排序 | 可用 | 同一 UP 连续,组内按发布时间倒序,组间按该 UP 最早视频发布时间倒序。唯一使用写鉴权的功能。 |
| 扫码授权与 Cookie 续期 | 可用 | 本地生成二维码;更新身份 Cookie 时保留设备指纹。 | | 扫码授权与 Cookie 续期 | 可用 | 终端打印 ASCII 二维码并写出 PNG;更新身份 Cookie 时保留设备指纹。 |
| 视频入库 | 可用 | 搜索候选经 Telegram 确认入库;私聊发送视频链接、BV 号、av 号或 `b23.tv` 短链手动入库。只写本地库。 | | 视频入库 | 可用 | 搜索候选经 Telegram 确认入库;私聊发送视频链接、BV 号、av 号或 `b23.tv` 短链手动入库。只写本地库。 |
| 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 | | 视频日快照 | 可用(P1) | 逐条采集播放、点赞、投币、收藏、转发、评论、弹幕。 |
| 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 | | 粉丝追踪 | 可用(P1) | 每日记录咻咻满等配置 UP 的粉丝数。 |
@@ -283,7 +283,7 @@ docker compose up -d --build
- 服务器配置需使用 `web.host = 0.0.0.0` 并设置 `web.auth_token`,否则服务拒绝启动; - 服务器配置需使用 `web.host = 0.0.0.0` 并设置 `web.auth_token`,否则服务拒绝启动;
- `storage.database_path` 使用 `/data/app.db`,`storage.backup_dir` 使用 `/data/backup`; - `storage.database_path` 使用 `/data/app.db`,`storage.backup_dir` 使用 `/data/backup`;
- 首次部署可先在本地执行 `uv run python app.py auth --account read` 扫码,再把生成好的 `config.json` 放到服务器; - 首次部署可先在本地执行 `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 ...` 回填粉丝历史; - 镜像包含 `tools/`,可直接在容器内执行 `python tools/import_fans_csv.py ...` 回填粉丝历史;
- 数据卷包含数据库、备份与预览库,建议定期备份 `/data`。 - 数据卷包含数据库、备份与预览库,建议定期备份 `/data`。
@@ -381,7 +381,7 @@ uv run python app.py auth --account write
uv run python app.py auth-check --account all uv run python app.py auth-check --account all
``` ```
授权流程在本地生成二维码图片并等待手机客户端扫码确认。成功后: 授权流程会在终端打印 UTF-8 半块字符二维码(TTY 下白底黑字,方便深色终端扫码),同时写出 PNG,并等待手机客户端扫码确认。`--no-browser` 只跳过打开图片,仍会打印终端二维码。成功后:
- 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段; - 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段;
- 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹; - 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹;
+19 -1
View File
@@ -45,12 +45,30 @@ def strip_identity(cookie: str) -> str:
return "; ".join(kept) 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]: def make_headers(cfg: dict, auth: bool = False, *, referer: str | None = None) -> dict[str, str]:
"""构造与 ``data/raw-request.txt`` 对齐的 B 站 Web 请求头。""" """构造与 ``data/raw-request.txt`` 对齐的 B 站 Web 请求头。"""
return { return {
"accept": "*/*", "accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "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", "dnt": "1",
"origin": "https://space.bilibili.com", "origin": "https://space.bilibili.com",
"priority": "u=1, i", "priority": "u=1, i",
+49 -4
View File
@@ -40,6 +40,47 @@ import qrcodegen # noqa: E402
BASE = "https://api.bilibili.com" BASE = "https://api.bilibili.com"
PASSPORT = "https://passport.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: 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: def run(self, *, timeout: int, no_browser: bool) -> int:
raw = self._load_raw() raw = self._load_raw()
credentials = raw["bilibili"][self.account] credentials = raw["bilibili"][self.account]
print(f"[auth] 为 {self.account} 账号申请扫码登录 ...") print(f"[auth] 为 {self.account} 账号申请扫码登录 ...", flush=True)
generated, _ = self._request( generated, _ = self._request(
credentials, f"{PASSPORT}/x/passport-login/web/qrcode/generate?source=main-fe-header", auth=False 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: if generated.get("code") != 0 or not key or not login_url:
print(f"[auth] 申请二维码失败: code={generated.get('code')} {generated.get('message')}") print(f"[auth] 申请二维码失败: code={generated.get('code')} {generated.get('message')}")
return 2 return 2
login_url = str(login_url)
qr_path = Path(tempfile.gettempdir()) / f"bili-fav-{self.account}-{key[:8]}.png" qr_path = Path(tempfile.gettempdir()) / f"bili-fav-{self.account}-{key[:8]}.png"
write_qr_png(str(login_url), qr_path) write_qr_png(login_url, qr_path)
print(f"[auth] 二维码已生成: {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: if not no_browser:
try: try:
open_path(qr_path) open_path(qr_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print(f"[auth] 打开图片失败: {exc},请手动打开二维码") print(f"[auth] 打开图片失败: {exc},请手动扫描终端二维码")
deadline, last = time.time() + timeout, None deadline, last = time.time() + timeout, None
while time.time() < deadline: while time.time() < deadline:
try: try:
+1 -1
View File
@@ -99,7 +99,7 @@ def _build_parser() -> argparse.ArgumentParser:
auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie") auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie")
auth.add_argument("--account", choices=("read", "write"), default="read", auth.add_argument("--account", choices=("read", "write"), default="read",
help="要授权的账号角色(默认 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", auth.add_argument("--timeout", type=int, default=180, metavar="SEC",
help="等待扫码的超时时间(默认 180 秒)") help="等待扫码的超时时间(默认 180 秒)")
auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态") auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态")
+111 -2
View File
@@ -1,16 +1,19 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""双账号 auth 纯函数与写回隔离测试(不联网): uv run python tests/test_auth.py""" """双账号 auth 纯函数与写回隔离测试(不联网): uv run python tests/test_auth.py"""
import io
import json import json
import os import os
import sys import sys
import tempfile import tempfile
from contextlib import redirect_stdout
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.bilibili.auth import ( # noqa: E402 from reaction_board.bilibili.auth import ( # noqa: E402
cookie_header,
cookie_value, cookie_value,
cookies_from_set_cookie, cookies_from_set_cookie,
make_headers, make_headers,
@@ -18,7 +21,14 @@ from reaction_board.bilibili.auth import ( # noqa: E402
parse_cookie, parse_cookie,
sessdata_expiry, 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" 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" 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") 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(): def test_make_headers_matches_raw_request():
headers = make_headers({"cookie": "SESSDATA=x; buvid3=y"}, auth=False) headers = make_headers({"cookie": "SESSDATA=x; buvid3=y"}, auth=False)
assert headers["origin"] == "https://space.bilibili.com" 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" {"cookie": ""}, referer="https://space.bilibili.com/1/favlist?fid=2"
) )
assert custom["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(): def main():
test_cookie_helpers() test_cookie_helpers()
test_finish_updates_only_selected_account_and_rolls_back() test_finish_updates_only_selected_account_and_rolls_back()
test_qr_png() test_qr_png()
test_render_qr_ascii_roundtrip_and_quiet_zone()
test_run_prints_ascii_qr_without_network()
test_make_headers_matches_raw_request() 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__": if __name__ == "__main__":