feat(auth): separate read and write bilibili accounts

This commit is contained in:
kairee committed 2026-10-31 20:01:21 +08:00
1 parent 5c2ab7b187
commit 178ab64b18
17 files changed
+671 -707

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
"""扫码授权流程;仅由 ``python app.py auth`` 调用。
认证结果只写入指定的 ``bilibili.read`` 或 ``bilibili.write`` 段,两个账号的
身份及设备指纹 Cookie 绝不互相覆盖。
"""
from __future__ import annotations
import json
import os
import shutil
import struct
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import webbrowser
import zlib
from pathlib import Path
from typing import Any, Callable, Mapping
from .auth import (
IDENTITY_KEYS,
cookie_value,
cookies_from_set_cookie,
gen_uuid,
make_headers,
merge_cookie,
parse_cookie,
sessdata_expiry,
)
ROOT = Path(__file__).resolve().parents[2]
VENDOR = ROOT / "vendor"
if str(VENDOR) not in sys.path:
sys.path.insert(0, str(VENDOR))
import qrcodegen # noqa: E402
BASE = "https://api.bilibili.com"
PASSPORT = "https://passport.bilibili.com"
def write_qr_png(text: str, path: str | Path, scale: int = 8, border: int = 4) -> Path:
"""将登录链接编码为不依赖图像库的 8-bit 灰度 QR PNG。"""
qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM)
size = qr.get_size()
dim = (size + border * 2) * scale
rows: list[bytes] = []
for y in range(dim):
my = y // scale - border
row = bytearray([0])
for x in range(dim):
mx = x // scale - border
dark = 0 <= mx < size and 0 <= my < size and qr.get_module(mx, my)
row.append(0 if dark else 255)
rows.append(bytes(row))
def chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
)
output = Path(path)
output.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", dim, dim, 8, 0, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(b"".join(rows), 9))
+ chunk(b"IEND", b"")
)
return output
def open_path(path: Path) -> bool:
if os.name == "nt":
os.startfile(path) # type: ignore[attr-defined]
return True
return webbrowser.open(path.as_uri())
def ensure_device_cookies(
credentials: dict[str, Any], get_json: Callable[[str, bool], Mapping[str, Any]]
) -> list[str]:
"""仅为当前账号补齐缺失设备字段,已有指纹不改动。"""
pairs = parse_cookie(str(credentials.get("cookie") or ""))
lower = {key.lower() for key, _ in pairs}
added: list[str] = []
if "buvid3" not in lower or "buvid4" not in lower:
try:
data = get_json(f"{BASE}/x/frontend/finger/spi", True).get("data") or {}
except Exception: # noqa: BLE001
data = {}
if data.get("b_3") and "buvid3" not in lower:
pairs.append(("buvid3", str(data["b_3"])))
lower.add("buvid3")
added.append("buvid3")
if data.get("b_4") and "buvid4" not in lower:
pairs.append(("buvid4", str(data["b_4"])))
lower.add("buvid4")
added.append("buvid4")
if "_uuid" not in lower:
pairs.append(("_uuid", gen_uuid()))
lower.add("_uuid")
added.append("_uuid")
if "b_nut" not in lower and "buvid3" in added:
pairs.append(("b_nut", str(int(time.time()))))
credentials["cookie"] = "; ".join(f"{key}={value}" for key, value in pairs)
return added
class AuthService:
def __init__(self, config_path: str | Path, *, account: str, logger=None) -> None:
if account not in {"read", "write"}:
raise ValueError(f"未知账号角色: {account}")
self.path = Path(config_path)
self.account = account
self.logger = logger
@property
def backup_path(self) -> Path:
return self.path.with_name(self.path.name + ".bak")
def _load_raw(self) -> dict[str, Any]:
with self.path.open(encoding="utf-8") as fh:
raw = json.load(fh)
if not isinstance(raw, dict):
raise ValueError("配置文件顶层必须是对象")
section = raw.get("bilibili")
if not isinstance(section, dict) or not isinstance(section.get(self.account), dict):
raise ValueError(f"缺少 bilibili.{self.account} 账号配置")
return raw
@staticmethod
def _request(credentials: Mapping[str, Any], url: str, *, auth: bool) -> tuple[dict[str, Any], list[str]]:
req = urllib.request.Request(url, headers=make_headers(dict(credentials), auth=auth))
with urllib.request.urlopen(req, timeout=30) as response:
return (
json.loads(response.read().decode("utf-8", "replace")),
response.headers.get_all("Set-Cookie") or [],
)
def _get_json(self, credentials: Mapping[str, Any], url: str, auth: bool) -> Mapping[str, Any]:
return self._request(credentials, url, auth=auth)[0]
def _write_raw(self, raw: Mapping[str, Any]) -> None:
tmp = self.path.with_name(self.path.name + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(raw, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, self.path)
def finish(self, raw: dict[str, Any], pairs: Mapping[str, str]) -> int:
"""写入单个账号、验证 nav;验证失败还原整个原配置文件。"""
credentials = raw["bilibili"][self.account]
old_cookie = str(credentials.get("cookie") or "")
merged = merge_cookie(old_cookie, dict(pairs))
if not cookie_value(merged, "SESSDATA") or not cookie_value(merged, "bili_jct"):
print("[auth] 合并后缺少 SESSDATA / bili_jct,中止")
return 2
credentials["cookie"] = merged
credentials["csrf"] = cookie_value(merged, "bili_jct")
added = ensure_device_cookies(
credentials,
lambda url, auth: self._get_json(credentials, url, auth),
)
new_mid = cookie_value(str(credentials["cookie"]), "DedeUserID")
if new_mid.isdigit():
credentials["mid"] = int(new_mid)
shutil.copy2(self.path, self.backup_path)
self._write_raw(raw)
try:
nav = self._get_json(credentials, f"{BASE}/x/web-interface/nav", True)
data = nav.get("data") or {}
ok = nav.get("code") == 0 and bool(data.get("isLogin"))
except Exception as exc: # noqa: BLE001
data, ok = {"uname": str(exc)}, False
if not ok:
shutil.copy2(self.backup_path, self.path)
print(f"[auth] {self.account} 登录校验失败,已回滚 config.json")
return 4
if isinstance(data.get("mid"), int):
credentials["mid"] = data["mid"]
self._write_raw(raw)
updated = sorted(key for key in pairs if key.lower() in IDENTITY_KEYS)
kept = [key for key in ("buvid3", "buvid4", "_uuid", "buvid_fp")
if cookie_value(str(credentials["cookie"]), key)]
print(f"[auth] {self.account} 已更新身份字段: {', '.join(updated)}")
if added:
print(f"[auth] {self.account} 补齐设备字段: {', '.join(added)}")
print(f"[auth] {self.account} 保留设备指纹: {', '.join(kept) if kept else '(无)'}")
expiry = sessdata_expiry(str(credentials["cookie"]))
if expiry:
print(f"[auth] 新 SESSDATA 到期: {time.strftime('%Y-%m-%d', time.localtime(expiry))}")
print(f"[auth] {self.account} 校验通过 ✅ mid={credentials['mid']} uname={data.get('uname')}")
return 0
def run(self, *, timeout: int, no_browser: bool) -> int:
raw = self._load_raw()
credentials = raw["bilibili"][self.account]
print(f"[auth] 为 {self.account} 账号申请扫码登录 ...")
generated, _ = self._request(
credentials, f"{PASSPORT}/x/passport-login/web/qrcode/generate?source=main-fe-header", auth=False
)
data = generated.get("data") or {}
key, login_url = data.get("qrcode_key"), data.get("url")
if generated.get("code") != 0 or not key or not login_url:
print(f"[auth] 申请二维码失败: code={generated.get('code')} {generated.get('message')}")
return 2
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}")
if not no_browser:
try:
open_path(qr_path)
except Exception as exc: # noqa: BLE001
print(f"[auth] 打开图片失败: {exc},请手动打开二维码")
deadline, last = time.time() + timeout, None
while time.time() < deadline:
try:
payload, set_cookies = self._request(
credentials,
f"{PASSPORT}/x/passport-login/web/qrcode/poll?qrcode_key={urllib.parse.quote(str(key))}",
auth=False,
)
except Exception as exc: # noqa: BLE001
print(f"[auth] 轮询异常: {exc}")
time.sleep(3)
continue
poll = payload.get("data") or {}
state = poll.get("code")
if state == 0:
pairs = cookies_from_set_cookie(set_cookies)
for part in urllib.parse.urlparse(str(poll.get("url") or "")).query.split("&"):
key_name, _, value = part.partition("=")
if key_name and value:
pairs.setdefault(key_name, value)
return self.finish(raw, pairs)
if state == 86038:
print("[auth] 二维码已过期,请重新运行 auth")
return 3
if state != last:
print(f"[auth] {(poll.get('message') or {86101: '未扫码', 86090: '已扫码,等待确认'}.get(state, f'状态 {state}'))} ...")
last = state
time.sleep(2)
print("[auth] 等待超时,二维码已失效,请重新运行 auth")
return 3
+48 -20
View File
@@ -19,6 +19,7 @@ import sys
from pathlib import Path
from typing import Sequence
from .bilibili.auth_flow import AuthService
from .bilibili.client import BilibiliClient, Pacer
from .config import ConfigError, ConfigStore
from .database import Database
@@ -58,7 +59,15 @@ def _build_parser() -> argparse.ArgumentParser:
snapshot.add_argument(
"--force", action="store_true", help="忽略当日完成标记,重新采集"
)
sub.add_parser("auth-check", parents=[common], help="检查 Cookie 登录态")
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("--timeout", type=int, default=180, metavar="SEC",
help="等待扫码的超时时间(默认 180 秒)")
auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态")
auth_check.add_argument("--account", choices=("read", "write", "all"), default="read",
help="要检查的账号角色(默认 read)")
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照")
run.add_argument(
"--once", action="store_true", help="只执行一次到期任务后退出"
@@ -82,11 +91,11 @@ def _print(payload) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2), flush=True)
def _client(cfg, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
def _client(credentials, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
return BilibiliClient(
cookie=cfg.bilibili.cookie,
csrf=cfg.bilibili.csrf,
mid=cfg.bilibili.mid,
cookie=credentials.cookie,
csrf=credentials.csrf,
mid=credentials.mid,
pacer=pacer,
retries=retries,
logger=logger,
@@ -120,7 +129,7 @@ def cmd_sync(args, logger: JsonLogger) -> int:
db = _open_db(cfg, logger)
try:
repo = Repository(db)
client = _client(cfg, logger)
client = _client(cfg.bilibili.read, logger)
service = FolderSyncService(store, repo, client, logger=logger)
summary = service.run(full_refresh=args.full)
payload = summary.as_dict()
@@ -139,7 +148,7 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
try:
repo = Repository(db)
client = _client(
cfg, logger, pacer=Pacer(1.0, 1.6), retries=1
cfg.bilibili.read, logger, pacer=Pacer(1.0, 1.6), retries=1
)
service = SnapshotService(store, repo, client, logger=logger)
summary = service.run_if_needed(force=args.force)
@@ -153,23 +162,40 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
db.close()
def cmd_auth_check(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
if not cfg.bilibili.cookie:
_print({"ok": False, "is_login": False, "message": "未配置 cookie"})
return 1
client = _client(cfg, logger, retries=1)
state = client.check_login()
_print({
def cmd_auth(args, logger: JsonLogger) -> int:
if args.timeout < 1:
raise ConfigError("auth --timeout 必须至少为 1 秒")
return AuthService(Path(args.config), account=args.account, logger=logger).run(
timeout=args.timeout, no_browser=args.no_browser
)
def _auth_check(cfg, account: str, logger: JsonLogger) -> dict[str, object]:
credentials = getattr(cfg.bilibili, account)
if not credentials.cookie:
return {"account": account, "ok": False, "is_login": False, "message": "未配置 cookie"}
state = _client(credentials, logger, retries=1).check_login()
return {
"account": account,
"ok": state.is_login,
"is_login": state.is_login,
"mid": state.mid,
"uname": state.uname,
"code": state.code,
"message": state.message,
})
return 0 if state.is_login else 1
}
def cmd_auth_check(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
if args.account != "all":
result = _auth_check(cfg, args.account, logger)
_print(result)
return 0 if result["ok"] else 1
results = [_auth_check(cfg, account, logger) for account in ("read", "write")]
_print({"ok": all(result["ok"] for result in results), "accounts": results})
return 0 if all(result["ok"] for result in results) else 1
def cmd_run(args, logger: JsonLogger) -> int:
@@ -179,9 +205,9 @@ def cmd_run(args, logger: JsonLogger) -> int:
last: dict[str, object] = {}
try:
repo = Repository(db)
folder_client = _client(cfg, logger)
folder_client = _client(cfg.bilibili.read, logger)
folder_service = FolderSyncService(store, repo, folder_client, logger=logger)
snapshot_client = _client(cfg, logger, pacer=Pacer(1.0, 1.6), retries=1)
snapshot_client = _client(cfg.bilibili.read, logger, pacer=Pacer(1.0, 1.6), retries=1)
snapshot_service = SnapshotService(store, repo, snapshot_client, logger=logger)
def run_sync():
@@ -284,6 +310,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return cmd_sync(args, logger)
if args.command == "snapshot":
return cmd_snapshot(args, logger)
if args.command == "auth":
return cmd_auth(args, logger)
if args.command == "auth-check":
return cmd_auth_check(args, logger)
if args.command == "run":
+39 -32
View File
@@ -1,13 +1,8 @@
# -*- coding: utf-8 -*-
"""运行期配置加载、校验与热加载。
支持两种写法:
* 新格式:``{"bilibili": {"mid": ..., "cookie": ...}, ...}``;
* 旧 ``favsort.py`` 格式:顶层 ``mid`` / ``cookie`` / ``csrf``。
旧的扁平字段仍然被读取,因此 ``bili-fav auth`` 写回的 config.json 无需迁移即可
被看板使用。敏感值只保存在配置对象中,日志输出必须先经过 ``logging_json`` 脱敏。
B 站凭据必须采用 ``bilibili.read`` / ``bilibili.write`` 双账号格式。敏感值只
保存在配置对象中,日志输出必须先经过 ``logging_json`` 脱敏。
"""
from __future__ import annotations
@@ -43,7 +38,10 @@ DEFAULT_HEAT_WEIGHTS: dict[str, float] = {
}
DEFAULT_CONFIG: dict[str, Any] = {
"bilibili": {"mid": 0, "csrf": "", "cookie": ""},
"bilibili": {
"read": {"mid": 0, "csrf": "", "cookie": ""},
"write": {"mid": 0, "csrf": "", "cookie": ""},
},
"telegram": {"enabled": False, "bot_token": "", "chat_id": 0},
"web": {
"host": "127.0.0.1",
@@ -73,9 +71,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
# 环境变量只作为 CI / 临时调试的覆盖入口,不作为主路径。
ENV_OVERRIDES: dict[str, tuple[tuple[str, ...], type]] = {
"BILI_COOKIE": (("bilibili", "cookie"), str),
"BILI_CSRF": (("bilibili", "csrf"), str),
"BILI_MID": (("bilibili", "mid"), int),
"BILI_READ_COOKIE": (("bilibili", "read", "cookie"), str),
"BILI_READ_CSRF": (("bilibili", "read", "csrf"), str),
"BILI_READ_MID": (("bilibili", "read", "mid"), int),
"BILI_WRITE_COOKIE": (("bilibili", "write", "cookie"), str),
"BILI_WRITE_CSRF": (("bilibili", "write", "csrf"), str),
"BILI_WRITE_MID": (("bilibili", "write", "mid"), int),
"TG_BOT_TOKEN": (("telegram", "bot_token"), str),
"TG_CHAT_ID": (("telegram", "chat_id"), int),
"DASHBOARD_AUTH_TOKEN": (("web", "auth_token"), str),
@@ -91,12 +92,18 @@ class ConfigError(ValueError):
@dataclass(frozen=True)
class BilibiliConfig:
class BilibiliCredentials:
mid: int
csrf: str
cookie: str
@dataclass(frozen=True)
class BilibiliConfig:
read: BilibiliCredentials
write: BilibiliCredentials
@dataclass(frozen=True)
class TelegramConfig:
enabled: bool
@@ -234,18 +241,6 @@ def _as_str_list(value: Any, name: str) -> list[str]:
return out
def _legacy_to_current(raw: Mapping[str, Any]) -> dict[str, Any]:
"""把 favsort 的扁平字段映射到新格式,不修改调用方对象。"""
data = copy.deepcopy(dict(raw))
if "bilibili" not in data:
data["bilibili"] = {
"mid": raw.get("mid", 0),
"csrf": raw.get("csrf", ""),
"cookie": raw.get("cookie", ""),
}
return data
def _validate_folders(value: Any) -> tuple[FolderConfig, ...]:
if not isinstance(value, list):
raise ConfigError("folders 必须是数组")
@@ -273,11 +268,17 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
b = data["bilibili"]
if not isinstance(b, Mapping):
raise ConfigError("bilibili 必须是对象")
bilibili = BilibiliConfig(
mid=_as_int(b.get("mid", 0), "bilibili.mid", minimum=0),
csrf=_as_str(b.get("csrf", "") or "", "bilibili.csrf"),
cookie=_as_str(b.get("cookie", "") or "", "bilibili.cookie"),
)
def credentials(account: str) -> BilibiliCredentials:
value = b.get(account)
if not isinstance(value, Mapping):
raise ConfigError(f"bilibili.{account} 必须是对象")
return BilibiliCredentials(
mid=_as_int(value.get("mid", 0), f"bilibili.{account}.mid", minimum=0),
csrf=_as_str(value.get("csrf", "") or "", f"bilibili.{account}.csrf"),
cookie=_as_str(value.get("cookie", "") or "", f"bilibili.{account}.cookie"),
)
bilibili = BilibiliConfig(read=credentials("read"), write=credentials("write"))
tg = data["telegram"]
if not isinstance(tg, Mapping):
@@ -431,8 +432,14 @@ def load_config(
raise ConfigError(f"无法读取配置文件: {config_path}: {exc}") from exc
if not isinstance(raw, Mapping):
raise ConfigError("配置文件顶层必须是 JSON 对象")
raw_bilibili = raw.get("bilibili")
if not isinstance(raw_bilibili, Mapping):
raise ConfigError("缺少 bilibili.read / bilibili.write 双账号配置")
for account in ("read", "write"):
if account not in raw_bilibili:
raise ConfigError(f"缺少 bilibili.{account} 账号配置")
data = _deep_merge(DEFAULT_CONFIG, _legacy_to_current(raw))
data = _deep_merge(DEFAULT_CONFIG, raw)
env_map = os.environ if env is None else env
for env_name, (target, caster) in ENV_OVERRIDES.items():
@@ -447,9 +454,9 @@ def load_config(
_set_path(data, target, value)
cfg = _build_config(data, config_path)
if require_auth and (not cfg.bilibili.cookie or not cfg.bilibili.csrf):
if require_auth and (not cfg.bilibili.read.cookie or not cfg.bilibili.read.csrf):
raise ConfigError(
"config.json 中的 cookie / csrf 不能为空(可先运行 bili-fav auth)"
"config.json 中的 bilibili.read.cookie / csrf 不能为空(可先运行 python app.py auth)"
)
return cfg
+1 -1
View File
@@ -231,7 +231,7 @@ class FolderSyncService:
infos = self.client.fav_infos(
folder.media_id,
sorted(targets),
folder_mid=cfg.bilibili.mid,
folder_mid=cfg.bilibili.read.mid,
)
for aid, resource in infos.items.items():
if aid not in remote:
+1 -1
View File
@@ -255,7 +255,7 @@ class SnapshotService:
# ------------------------------------------------------------ internals
def _check_auth(self, cfg: AppConfig, now: int) -> tuple[bool, str]:
if not cfg.bilibili.cookie:
if not cfg.bilibili.read.cookie:
self.repo.kv_set("auth:last_check", str(now))
self.repo.kv_set("auth:ok", "0")
self.repo.kv_set("auth:message", "未配置 cookie")