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

+9 -7
View File
@@ -71,11 +71,12 @@ uv run python --version
# 查看现有收藏夹工具帮助
uv run bili-fav help
# 扫码登录并原子更新 config.json
uv run bili-fav auth
# 扫码登录并原子更新指定账号
uv run python app.py auth --account read
uv run python app.py auth --account write
# 检查现有 B 站登录状态
uv run bili-fav auth --check
# 检查两套 B 站登录状态
uv run python app.py auth-check --account all
# 执行当前无网络依赖的测试
uv run python tests/test_auth.py
@@ -200,7 +201,8 @@ PRAGMA synchronous = NORMAL;
│ ├── logging_json.py # 脱敏 JSON 日志
│ ├── bilibili/
│ │ ├── client.py # HTTP、WBI、重试与限速
│ │ ├── auth.py # 从 favsort 抽取的扫码授权能力
│ │ ├── auth.py # Cookie、设备指纹与请求头工具
│ │ ├── auth_flow.py # app.py auth 的扫码与原子写回流程
│ │ └── favorite.py # 加入收藏夹与排序定位
│ ├── services/
│ │ ├── discovery.py
@@ -224,7 +226,7 @@ PRAGMA synchronous = NORMAL;
└── data/ # 本地运行期数据,默认不提交
```
`favsort.py` 继续保持可独立运行。P0 实施时优先抽取其通用的 Cookie、请求头、扫码授权和收藏夹 API 逻辑;不要直接复制出第二套不一致的身份与请求实现。
`favsort.py` 继续保持可独立运行,但只操作 `bilibili.write`;扫码授权统一由 `app.py auth --account read|write` 提供。Cookie、设备指纹与请求头逻辑必须共用 `reaction_board.bilibili.auth`。
---
@@ -275,7 +277,7 @@ PRAGMA synchronous = NORMAL;
```
- `folders` 中的年度夹由人工创建,系统只读;
- 兼容现有 `favsort.py` 的扁平 `mid` / `csrf` / `cookie` 写法;存在 `bilibili` 嵌套对象时以嵌套值为准;
- 配置必须为 `bilibili.read` / `bilibili.write` 双账号结构;不兼容顶层扁平 `mid` / `csrf` / `cookie`;
- P0 已实现的命令行:`python app.py init`、`python app.py sync [--full]`、`python app.py status`。
### 5.2 安全规则- Windows 本地开发时应限制 `config.json` 的文件访问权限;部署到 Linux 时权限必须为 `600`;
+37 -20
View File
@@ -97,8 +97,13 @@ uv run python app.py snapshot
# 忽略当日完成标记,重新采集
uv run python app.py snapshot --force
# 检查 Cookie 登录态
uv run python app.py auth-check
# 分别为只读和写入账号扫码授权(默认 read)
uv run python app.py auth
uv run python app.py auth --account write
# 检查指定账号或两套账号的登录态
uv run python app.py auth-check --account read
uv run python app.py auth-check --account all
```
- `snapshot` 对 `active` / `unfaved` 视频逐条请求 `view`,每条请求间隔 1.0~1.6 秒;
@@ -108,7 +113,7 @@ uv run python app.py auth-check
- 粉丝数写入 `up_stat`,失败同样写 `ok=0`;
- 当日全部视频与粉丝采集成功后写入完成标记;服务启动时若当天未完成会自动补跑;
- 每次采集后生成 `data/backup/app-YYYYMMDD.db`,按 `storage.backup_keep_days` 清理旧日备份;
- Cookie 失效只告警,不阻塞匿名 `view` 采集;`auth-check` 可单独检查。
- Cookie 失效只告警,不阻塞匿名 `view` 采集;P0/P1 只使用 `bilibili.read`。
首次真实全量采集结果:795 条视频全部成功、0 失败、0 移除,耗时约 19 分钟;粉丝 `follower=2767270`;日备份为 `data/backup/app-20261031.db`。
@@ -120,9 +125,20 @@ uv run python app.py auth-check
Copy-Item config.example.json config.json
```
`config.json` 已被 `.gitignore` 忽略,**不得提交**。其中:
`config.json` 已被 `.gitignore` 忽略,**不得提交**。配置必须使用双账号格式:
- `mid` / `cookie` / `csrf`:B 站账号 UID、完整 Cookie 与 `bili_jct`(兼容 `favsort.py` 的扁平写法;也支持 `bilibili` 嵌套对象);
```json
{
"bilibili": {
"read": { "mid": 0, "cookie": "...", "csrf": "..." },
"write": { "mid": 0, "cookie": "...", "csrf": "..." }
}
}
```
- `bilibili.read`:年度收藏夹同步、视频数据快照、粉丝数与其他只读查询使用;
- `bilibili.write`:后续加入目标收藏夹、回读验证与现有 `favsort.py` 排序使用;
- 两个账号的 Cookie、CSRF 与设备指纹严格隔离;顶层 `mid` / `cookie` / `csrf` **不再支持**;
- `overrides`:可选,为代发或联合投稿视频指定归属 UP;
- `folders`:年度收藏夹配置(`year` + `media_id` + `title`),系统不自动建夹;
- `up_blacklist_seed`:首次启动写入数据库的 UP 黑名单种子;
@@ -140,9 +156,9 @@ Copy-Item config.example.json config.json
# 查看全部命令;无需 config.json
uv run bili-fav help
# 扫码登录,更新 config.json 中失效的身份 Cookie
uv run bili-fav auth
uv run bili-fav auth --check
# 为写账号扫码登录与检查状态(认证入口统一在 app.py)
uv run python app.py auth --account write
uv run python app.py auth-check --account write
# 列出本人创建的收藏夹
uv run bili-fav folders
@@ -172,38 +188,39 @@ uv run bili-fav verify <media_id>
## 扫码授权
当接口返回 `-101` 或 Cookie 即将过期时,运行:
当接口返回 `-101` 或 Cookie 即将过期时,使用统一认证入口:
```powershell
uv run bili-fav auth
uv run bili-fav auth --check
uv run python app.py auth --account read
uv run python app.py auth --account write
uv run python app.py auth-check --account all
```
授权流程在本地生成二维码图片并等待手机客户端扫码确认。成功后:
- 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段;
- 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹;
- 将 `csrf` 同步为新的 `bili_jct`;
- 将选中账号的 `csrf` 同步为新的 `bili_jct`,并从 `nav` 回写该账号实际 mid;
- 原子写入 `config.json`,并保留 `config.json.bak`;
- 通过 `x/web-interface/nav` 验证新登录态,失败时自动回滚。
- 通过 `x/web-interface/nav` 验证新登录态,失败时自动回滚;不会修改另一个账号。
常用选项:
```powershell
uv run bili-fav auth --no-browser # 只生成二维码图片并输出路径
uv run bili-fav auth --timeout 180 # 修改扫码等待时间(秒)
uv run python app.py auth --account write --no-browser
uv run python app.py auth --account read --timeout 180
```
## B 站访问与安全约束
| 接口 | 当前用途 | 身份 Cookie |
|---|---|---|
| `fav/resource/ids` | 收藏夹资源 ID | 不需要账号身份;可匿名读取。 |
| `fav/resource/infos` | 收藏夹元数据 | 不需要账号身份,但需要设备指纹,完全无 Cookie 时可能返回 HTTP 412。 |
| `fav/folder/created/list-all` | 列出自建收藏夹 | 需要完整登录 Cookie。 |
| `fav/resource/sort` | 收藏夹排序 | 写操作;需要完整 Cookie 和 `csrf`。 |
| `fav/resource/ids` / `infos` | 年度收藏夹同步与排序计划 | `read` 用于 P0;`favsort.py` 计划使用 `write`。 |
| `view` / `relation/stat` / `nav` | P1 快照与 read 登录态 | `read`。 |
| `fav/folder/created/list-all` | 列出目标自建收藏夹 | `write`。 |
| `fav/resource/deal` / `sort` | 加入收藏夹、收藏夹排序 | `write`,需要完整 Cookie 和 CSRF。 |
现有工具对 `ids` / `infos` 默认剥离账号身份 Cookie,仅保留设备指纹;如果只读请求失败,才使用完整 Cookie 重试。所有写操作使用完整 Cookie 与 CSRF。
只读客户端对 `ids` / `infos` 默认剥离 `read` 身份 Cookie,仅保留该账号设备指纹;如果失败才使用完整 `read` Cookie 重试。写操作一律使用完整 `write` Cookie 与 CSRF。
`x/v3/fav/resource/sort` 的实测语义为:
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""看板服务入口。
P1 提供 ``init`` / ``sync`` / ``snapshot`` / ``auth-check`` / ``run`` / ``status``
P1 提供 ``init`` / ``sync`` / ``snapshot`` / ``auth`` / ``auth-check`` / ``run`` / ``status``
子命令;后续阶段会在同一入口启动本地 Web 服务。
"""
+12 -3
View File
@@ -1,7 +1,16 @@
{
"mid": 0,
"csrf": "从 cookie 中的 bili_jct 取值",
"cookie": "粘贴浏览器请求头里的完整 Cookie(需含 SESSDATA、bili_jct、DedeUserID 等)",
"bilibili": {
"read": {
"mid": 0,
"csrf": "从 read 账号 cookie 中的 bili_jct 取值",
"cookie": "只读账号的完整 Cookie(需含 SESSDATA、bili_jct、DedeUserID 等)"
},
"write": {
"mid": 0,
"csrf": "从 write 账号 cookie 中的 bili_jct 取值",
"cookie": "写入账号的完整 Cookie(用于加入收藏夹和排序)"
}
},
"overrides": {},
"sleep": {
"min": 2.5,
+21 -324
View File
@@ -10,7 +10,6 @@ bili-fav / favsort —— B 站收藏夹批量重排序工具
指令
python favsort.py help [子命令]
python favsort.py auth [--check] [--no-browser] [--timeout SEC]
python favsort.py folders
python favsort.py plan <media_id> [--folder-mid N] [-v]
python favsort.py show <media_id>
@@ -18,55 +17,28 @@ bili-fav / favsort —— B 站收藏夹批量重排序工具
python favsort.py run <media_id> [--verify-every N]
python favsort.py verify <media_id>
鉴权
auth 会申请扫码登录,在本地生成二维码图片(vendor/qrcodegen.py,纯 Python、MIT,
无需 pip 安装),用默认看图工具打开给你扫;拿到新的身份 cookie 后只替换
SESSDATA / bili_jct / DedeUserID 等身份字段,buvid3 / buvid4 / _uuid / buvid_fp
等设备指纹一律保留。只有设备 cookie 缺失时才用公开的 finger/spi 接口补,
拿不到的(如 buvid_fp)保持原样。
ids / infos / folders 为只读请求,默认剥离账号身份 cookie;sort 为写操作,
始终使用完整 cookie + csrf。
本工具只处理写账号所属收藏夹的排序。扫码授权统一使用
``python app.py auth --account write``;所有 API 请求均使用 ``bilibili.write``。
"""
import argparse
import json
import os
import random
import shutil
import struct
import sys
import tempfile
import time
import urllib.parse
import urllib.request
import webbrowser
import zlib
sys.stdout.reconfigure(encoding="utf-8")
BASE = "https://api.bilibili.com"
PASSPORT = "https://passport.bilibili.com"
ROOT = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(ROOT, "data")
CONFIG_PATH = os.path.join(ROOT, "config.json")
CONFIG_BACKUP = CONFIG_PATH + ".bak"
sys.path.insert(0, os.path.join(ROOT, "vendor"))
try:
import qrcodegen # noqa: E402 纯 Python,MIT,见 vendor/qrcodegen.py
except ImportError: # pragma: no cover
qrcodegen = None
from reaction_board.bilibili.auth import ( # noqa: E402
IDENTITY_KEYS,
cookie_value,
cookies_from_set_cookie,
gen_uuid,
make_headers,
merge_cookie,
parse_cookie,
sessdata_expiry,
strip_identity,
)
CHUNK = 20 # infos 接口单次 resources 数量(实测 20 稳定)
@@ -75,18 +47,11 @@ API = {
"infos": "{base}/x/v3/fav/resource/infos?resources={res}&platform=web&folder_mid={fmid}&folder_id={mid}",
"sort": "{base}/x/v3/fav/resource/sort",
"folders": "{base}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp",
"nav": "{base}/x/web-interface/nav",
"spi": "{base}/x/frontend/finger/spi",
"qr_generate": "{passport}/x/passport-login/web/qrcode/generate?source=main-fe-header",
"qr_poll": "{passport}/x/passport-login/web/qrcode/poll?qrcode_key={key}",
}
# ---------------------------------------------------------------- config
DEFAULT_CONFIG = {
"mid": 0,
"csrf": "",
"cookie": "",
"overrides": {},
"sleep": {
"min": 2.5,
@@ -107,13 +72,24 @@ def load_config(require_auth=True):
f"请复制 config.example.json 为 config.json 并填入 cookie / csrf / mid。"
)
with open(CONFIG_PATH, encoding="utf-8") as f:
cfg = json.load(f)
raw = json.load(f)
bilibili = raw.get("bilibili")
if not isinstance(bilibili, dict) or not isinstance(bilibili.get("write"), dict):
raise SystemExit("config.json 必须配置 bilibili.write(扫码请运行 python app.py auth --account write)")
write = bilibili["write"]
cfg = {
"mid": write.get("mid", 0),
"csrf": write.get("csrf", ""),
"cookie": write.get("cookie", ""),
"overrides": raw.get("overrides", {}),
"sleep": raw.get("sleep", {}),
}
for k, v in DEFAULT_CONFIG.items():
cfg.setdefault(k, v)
for k, v in DEFAULT_CONFIG["sleep"].items():
cfg["sleep"].setdefault(k, v)
if require_auth and (not cfg["cookie"] or not cfg["csrf"]):
raise SystemExit("config.json 中的 cookie / csrf 不能为空(可先运行 python favsort.py auth)")
raise SystemExit("config.json 中的 bilibili.write.cookie / csrf 不能为空(可先运行 python app.py auth --account write)")
return cfg
@@ -152,95 +128,6 @@ def post_form(cfg, url, data):
return json.loads(r.read().decode("utf-8", "replace"))
# ---------------------------------------------------------------- cookies
def ensure_device_cookies(cfg):
"""补齐缺失的设备 cookie;已有的一律不动(设备指纹换新反而更容易触发风控)。"""
pairs = parse_cookie(cfg["cookie"])
lower = {k.lower() for k, _ in pairs}
added = []
if "buvid3" not in lower or "buvid4" not in lower:
try:
r = get_json(cfg, API["spi"].format(base=BASE), auth=True)
data = r.get("data") or {}
except Exception: # noqa: BLE001 补不到就算了,保留原有数据
data = {}
if data.get("b_3") and "buvid3" not in lower:
pairs.append(("buvid3", data["b_3"]))
lower.add("buvid3")
added.append("buvid3")
if data.get("b_4") and "buvid4" not in lower:
pairs.append(("buvid4", 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()))))
added.append("b_nut")
cfg["cookie"] = "; ".join(f"{k}={v}" for k, v in pairs)
return added
def save_config(cfg):
"""原子写入 config.json,并留一份 .bak。"""
if os.path.exists(CONFIG_PATH):
shutil.copy2(CONFIG_PATH, CONFIG_BACKUP)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, CONFIG_PATH)
# ---------------------------------------------------------------- qr
def write_qr_png(text, path, scale=8, border=4):
"""把文本编码成 QR 并写成 8bit 灰度 PNG(不依赖任何图像库)。"""
if qrcodegen is None:
raise RuntimeError("缺少 vendor/qrcodegen.py")
qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM)
size = qr.get_size()
dim = (size + border * 2) * scale
rows = []
for y in range(dim):
my = y // scale - border
row = bytearray([0]) # PNG 行过滤类型 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))
raw = b"".join(rows)
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", dim, dim, 8, 0, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
with open(path, "wb") as f:
f.write(png)
return path
def open_path(path):
"""用系统默认程序打开文件(Windows 用 startfile,其它平台用 webbrowser)。"""
if os.name == "nt":
os.startfile(path) # type: ignore[attr-defined]
return True
return webbrowser.open("file://" + path)
def fetch_ids(cfg, media_id):
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id))
if r.get("code") != 0:
@@ -419,185 +306,6 @@ def load_plan(media_id):
# ---------------------------------------------------------------- commands
def poll_qr(cfg, qrcode_key):
"""轮询扫码状态,同时拿到本轮的 Set-Cookie。"""
url = API["qr_poll"].format(passport=PASSPORT, key=urllib.parse.quote(qrcode_key))
req = urllib.request.Request(url, headers=make_headers(cfg, auth=False))
with urllib.request.urlopen(req, timeout=30) as r:
payload = json.loads(r.read().decode("utf-8", "replace"))
return payload, (r.headers.get_all("Set-Cookie") or [])
def fetch_nav(cfg):
return get_json(cfg, API["nav"].format(base=BASE), auth=True)
def auth_check(cfg):
"""只读:检查当前 cookie 是否有效、SESSDATA 何时到期、设备 cookie 是否齐全。"""
device = [k for k in ("buvid3", "buvid4", "_uuid", "buvid_fp")
if cookie_value(cfg["cookie"], k)]
print(f"[auth] 设备 cookie: {', '.join(device) if device else '(缺失)'}")
if not device:
print("[auth] 提示:缺少 buvid3/buvid4 等设备指纹,infos 接口可能返回 412")
exp = sessdata_expiry(cfg["cookie"])
if exp:
days = (exp - time.time()) / 86400
print(f"[auth] SESSDATA 到期: {time.strftime('%Y-%m-%d', time.localtime(exp))}"
f"(剩余 {days:.0f} 天)")
if not cfg["cookie"]:
print("[auth] 未登录:config.json 里没有 cookie")
return 1
try:
r = fetch_nav(cfg)
except Exception as e: # noqa: BLE001
print(f"[auth] 检查失败: {e}")
return 1
data = r.get("data") or {}
if r.get("code") != 0 or not data.get("isLogin"):
print(f"[auth] 登录已失效 ❌ code={r.get('code')} {r.get('message')}")
print("[auth] 运行 `python favsort.py auth` 扫码重新登录")
return 1
print(f"[auth] 已登录 ✅ mid={data.get('mid')} uname={data.get('uname')}")
if exp and exp < time.time():
print("[auth] 警告:SESSDATA 时间戳已过期但接口仍返回登录态,建议尽快更新")
return 0
def finish_auth(cfg, pairs):
"""合并新身份 cookie -> 补设备 cookie -> 写配置 -> 校验,失败回滚。"""
old_cookie = cfg["cookie"]
merged = merge_cookie(old_cookie, pairs)
if not cookie_value(merged, "SESSDATA") or not cookie_value(merged, "bili_jct"):
print("[auth] 合并后缺少 SESSDATA / bili_jct,中止")
return 2
cfg["cookie"] = merged
cfg["csrf"] = cookie_value(merged, "bili_jct")
updated = sorted(k for k in pairs if k.lower() in IDENTITY_KEYS)
print(f"[auth] 登录成功,更新身份字段: {', '.join(updated)}")
added = ensure_device_cookies(cfg)
if added:
print(f"[auth] 补齐缺失的设备字段: {', '.join(added)}")
kept = [k for k in ("buvid3", "buvid4", "_uuid", "buvid_fp")
if cookie_value(cfg["cookie"], k)]
print(f"[auth] 保留设备指纹: {', '.join(kept) if kept else '(无)'}")
if not cookie_value(cfg["cookie"], "buvid3") or not cookie_value(cfg["cookie"], "buvid4"):
print("[auth] 警告:buvid3/buvid4 仍缺失,infos 接口可能返回 412")
new_mid = cookie_value(cfg["cookie"], "DedeUserID")
if new_mid and new_mid.isdigit() and int(cfg.get("mid") or 0) != int(new_mid):
print(f"[auth] 注意:登录账号 mid={new_mid},与 config 里的 mid={cfg.get('mid')} 不同,"
f"已同步为 {new_mid}")
cfg["mid"] = int(new_mid)
exp = sessdata_expiry(cfg["cookie"])
if exp:
print(f"[auth] 新 SESSDATA 到期: "
f"{time.strftime('%Y-%m-%d', time.localtime(exp))}")
save_config(cfg)
print(f"[auth] 已写入 {CONFIG_PATH}(备份 {os.path.basename(CONFIG_BACKUP)})")
try:
r = fetch_nav(cfg)
ok = r.get("code") == 0 and (r.get("data") or {}).get("isLogin")
uname = (r.get("data") or {}).get("uname")
except Exception as e: # noqa: BLE001
ok, uname = False, str(e)
if not ok:
if os.path.exists(CONFIG_BACKUP):
shutil.copy2(CONFIG_BACKUP, CONFIG_PATH)
print("[auth] 登录校验失败,已回滚 config.json")
else:
print("[auth] 登录校验失败(没有备份可回滚)")
return 4
print(f"[auth] 校验通过 ✅ uname={uname}")
return 0
def cmd_auth(cfg, args):
if args.check:
return auth_check(cfg)
print("[auth] 申请扫码登录 ...")
gen = get_json(cfg, API["qr_generate"].format(passport=PASSPORT), auth=False)
data = gen.get("data") or {}
qrcode_key, url = data.get("qrcode_key"), data.get("url")
if gen.get("code") != 0 or not qrcode_key or not url:
print(f"[auth] 申请二维码失败: code={gen.get('code')} {gen.get('message')}")
return 2
qr_path = os.path.join(tempfile.gettempdir(), f"bili-fav-login-{qrcode_key[:8]}.png")
try:
write_qr_png(url, qr_path)
print(f"[auth] 二维码已生成: {qr_path}")
except Exception as e: # noqa: BLE001
print(f"[auth] 本地生成二维码失败: {e}")
print(f"[auth] 请手动处理这个链接: {url}")
else:
if not args.no_browser:
try:
if open_path(qr_path):
print("[auth] 已用默认看图工具打开二维码")
except Exception as e: # noqa: BLE001
print(f"[auth] 打开图片失败: {e},请手动打开上面的路径")
print(f"[auth] 请在 {args.timeout} 秒内用 B 站客户端扫码并确认(二维码约 180 秒有效)")
sys.stdout.flush()
deadline = time.time() + args.timeout
last = None
while time.time() < deadline:
try:
payload, set_cookies = poll_qr(cfg, qrcode_key)
except Exception as e: # noqa: BLE001
print(f"[auth] 轮询异常: {e}")
time.sleep(3)
continue
data = payload.get("data") or {}
state = data.get("code")
if state is None:
print(f"[auth] 轮询失败: code={payload.get('code')} {payload.get('message')}")
return 2
if state == 0:
pairs = cookies_from_set_cookie(set_cookies)
# 老版本会把 cookie 放在跨域 URL 的 query 里,作为兜底保留
if data.get("url"):
for part in urllib.parse.urlparse(data["url"]).query.split("&"):
k, _, v = part.partition("=")
if k and v:
pairs.setdefault(k, v)
got = "; ".join(f"{k}={v}" for k, v in pairs.items())
if not cookie_value(got, "SESSDATA") or not cookie_value(got, "bili_jct"):
print("[auth] 扫码成功,但响应里没有 SESSDATA / bili_jct,无法继续")
return 2
return finish_auth(cfg, pairs)
if state == 86038:
print("[auth] 二维码已过期,请重新运行 auth")
return 3
if state != last:
msg = data.get("message") or {
86101: "未扫码",
86090: "已扫码,等待确认",
}.get(state, f"状态 {state}")
print(f"[auth] {msg} ...")
last = state
time.sleep(2)
print("[auth] 等待超时,二维码已失效,请重新运行 auth")
return 3
def cmd_folders(cfg, args):
# 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3),
@@ -823,8 +531,7 @@ def cmd_verify(cfg, args):
# ---------------------------------------------------------------- main
COMMAND_HELP = {
"auth": "扫码登录并更新 config.json 里失效的身份 cookie(保留设备指纹)",
"folders": "列出自己(config.mid)的自建收藏夹及其 media_id",
"folders": "列出写账号(bilibili.write.mid)的自建收藏夹及其 media_id",
"plan": "拉取收藏夹数据并生成排序计划(只读,不改动收藏夹)",
"show": "查看已生成的计划与完整移动脚本(离线)",
"replan": "离线重算移动脚本(不联网,不改目标顺序)",
@@ -844,18 +551,10 @@ def build_parser():
sub.add_parser("folders", help=COMMAND_HELP["folders"])
pa = sub.add_parser("auth", help=COMMAND_HELP["auth"])
pa.add_argument("--check", action="store_true",
help="只检查当前登录状态与到期时间,不修改配置")
pa.add_argument("--no-browser", action="store_true",
help="不自动打开二维码图片,只打印图片路径")
pa.add_argument("--timeout", type=int, default=180, metavar="SEC",
help="等待扫码的超时时间(默认 180 秒,与二维码有效期一致)")
pp = sub.add_parser("plan", help=COMMAND_HELP["plan"])
pp.add_argument("media_id", type=int, help="收藏夹 media_id(数字,见 folders 输出)")
pp.add_argument("--folder-mid", type=int, default=None, metavar="N",
help="收藏夹归属用户 mid(默认取 config.json 的 mid)")
help="收藏夹归属用户 mid(默认取 bilibili.write.mid)")
pp.add_argument("--verbose", "-v", action="store_true", help="打印完整移动脚本")
ps = sub.add_parser("show", help=COMMAND_HELP["show"])
@@ -907,14 +606,13 @@ def print_help(parser, sub, topic=None):
else:
print(f" {a.dest.upper():<26} {a.help or ''}")
print("\n示例:")
print(" python favsort.py auth")
print(" python favsort.py auth --check")
print(" python app.py auth --account write")
print(" python favsort.py folders")
print(" python favsort.py plan 3700023351 -v")
print(" python favsort.py run 3700023351 --verify-every 25")
print(" python favsort.py verify 3700023351")
print("\n鉴权:auth 扫码后只更新身份 cookie,保留设备指纹;"
"只读请求自动去掉账号身份;run 的 sort 仍使用完整 cookie + csrf。")
print("\n鉴权:使用 python app.py auth --account write;"
"本工具的所有请求使用 bilibili.write,run 的 sort 使用完整 Cookie + csrf。")
return 0
@@ -925,11 +623,10 @@ def main():
if args.cmd in (None, "help"):
return print_help(p, sub, getattr(args, "topic", None))
cfg = load_config(require_auth=args.cmd != "auth")
cfg = load_config(require_auth=True)
ensure_data_dir()
handler = {
"auth": cmd_auth,
"folders": cmd_folders,
"plan": cmd_plan,
"show": cmd_show,
+13 -5
View File
@@ -90,9 +90,16 @@
```jsonc
{
"bilibili": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<bili_jct>",
"mid": 28802351
"read": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<read bili_jct>",
"mid": 28802351
},
"write": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<write bili_jct>",
"mid": 0
}
},
"telegram": {
"bot_token": "123456:ABC...",
@@ -126,7 +133,8 @@
- **黑名单本身放数据库**(`up_blacklist` 表),因为要从通知里动态拉黑;配置文件只做初始种子;
- 日志里**永不打印 token / cookie**,启动时只打印"已配置/未配置";
- 容器里把配置目录挂成**可写卷**(`-v ./config:/config`,权限 600),因为 `auth` 扫码续期后要回写 cookie;
- `python app.py auth` 复用 `bili-fav` 的扫码流程,只替换身份字段、保留设备指纹;
- `python app.py auth --account read|write` 对指定账号扫码续期,只替换该账号身份字段、保留该账号设备指纹;
- `read` 只用于年度收藏夹同步、快照和查询;`write` 只用于目标收藏夹、`deal` 和 `sort` 写操作;不支持顶层扁平凭据;
- 可选的 `ENV` 覆盖(`TG_BOT_TOKEN` 等)留给 CI/临时调试,不作为主路径。
### 1.6 初始黑名单(已核对昵称,实测 2026-10-31)
@@ -725,7 +733,7 @@ storage:SQLite WAL,单写线程
| 风控 `-352/-412` | 采集中断 | 低(实测未见) | 限速 1.2s + 抖动 + 指数退避 + 告警 |
| 跨年切换遗漏 | 新视频进错夹 | 低 | 按 pubdate 选夹 + 1 月 1 日提醒 |
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
| cookie 过期 | 收藏夹相关接口失败 | 中 | 复用 `bili-fav auth` 扫码续期;`view`/搜索匿名可用作降级 |
| cookie 过期 | 收藏夹相关接口失败 | 中 | 使用 `python app.py auth --account read|write` 扫码续期;`view`/搜索匿名可用作降级 |
---
+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")
+61 -143
View File
@@ -1,170 +1,88 @@
# -*- coding: utf-8 -*-
"""auth 相关纯函数自测(不联网): python tests/test_auth.py"""
"""双账号 auth 纯函数与写回隔离测试(不联网): uv run python tests/test_auth.py"""
import json
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
import favsort as f # noqa: E402
OLD = (
"buvid3=OLD-B3; buvid4=OLD-B4; _uuid=OLD-UUID; buvid_fp=OLD-FP; "
"b_nut=1700000000; SESSDATA=old%2C1700000000%2Cabc; bili_jct=oldcsrf; "
"DedeUserID=28802351; DedeUserID__ckMd5=oldmd5; sid=oldsid; b_lsid=OLDL; "
"bili_ticket=ticket; home_feed_column=5"
from reaction_board.bilibili.auth import ( # noqa: E402
cookie_value,
cookies_from_set_cookie,
merge_cookie,
parse_cookie,
sessdata_expiry,
)
from reaction_board.bilibili.auth_flow import AuthService, write_qr_png # 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"
NEW = {"SESSDATA": "new%2C1800000000%2Cxyz", "bili_jct": "newcsrf", "DedeUserID": "99"}
def test_parse_and_merge():
pairs = f.parse_cookie(OLD)
assert pairs[0] == ("buvid3", "OLD-B3")
assert f.cookie_value(OLD, "SESSDATA") == "old%2C1700000000%2Cabc"
new = {
"SESSDATA": "new%2C1800000000%2Cxyz",
"bili_jct": "newcsrf",
"DedeUserID": "28802351",
"DedeUserID__ckMd5": "newmd5",
"sid": "newsid",
def raw_config():
return {
"bilibili": {
"read": {"mid": 11, "csrf": "readcsrf", "cookie": READ_OLD},
"write": {"mid": 22, "csrf": "writecsrf", "cookie": WRITE_OLD},
},
"folders": [],
}
merged = f.merge_cookie(OLD, new)
# 身份字段被替换
assert f.cookie_value(merged, "SESSDATA") == "new%2C1800000000%2Cxyz"
assert f.cookie_value(merged, "bili_jct") == "newcsrf"
assert f.cookie_value(merged, "sid") == "newsid"
# 设备指纹原样保留
assert f.cookie_value(merged, "buvid3") == "OLD-B3"
assert f.cookie_value(merged, "buvid4") == "OLD-B4"
assert f.cookie_value(merged, "_uuid") == "OLD-UUID"
assert f.cookie_value(merged, "buvid_fp") == "OLD-FP"
assert f.cookie_value(merged, "b_nut") == "1700000000"
assert f.cookie_value(merged, "home_feed_column") == "5"
# 旧会话字段在没有新值时应被丢弃
assert f.cookie_value(merged, "b_lsid") == ""
def test_set_cookie_and_url_fallback():
headers = [
"SESSDATA=abc%2C1800000000%2Cdef; Path=/; Domain=.bilibili.com; HttpOnly",
"bili_jct=csrf2; Domain=.bilibili.com",
"buvid3=SHOULD-NOT-OVERWRITE; Domain=.bilibili.com",
]
got = f.cookies_from_set_cookie(headers)
assert got["SESSDATA"] == "abc%2C1800000000%2Cdef"
assert got["bili_jct"] == "csrf2"
merged = f.merge_cookie(OLD, got)
assert f.cookie_value(merged, "buvid3") == "OLD-B3" # 设备字段不被 Set-Cookie 覆盖
def test_cookie_helpers():
assert parse_cookie(READ_OLD)[0] == ("buvid3", "READ-B3")
merged = merge_cookie(WRITE_OLD, NEW)
assert cookie_value(merged, "SESSDATA") == "new%2C1800000000%2Cxyz"
assert cookie_value(merged, "bili_jct") == "newcsrf"
assert cookie_value(merged, "buvid3") == "WRITE-B3"
assert sessdata_expiry(merged) == 1800000000
headers = ["SESSDATA=x; Path=/", "bili_jct=y; Domain=.bilibili.com"]
assert cookies_from_set_cookie(headers) == {"SESSDATA": "x", "bili_jct": "y"}
def test_expiry():
assert f.sessdata_expiry(OLD) == 1700000000
assert f.sessdata_expiry("SESSDATA=bad") is None
assert f.sessdata_expiry("") is None
def test_finish_updates_only_selected_account_and_rolls_back():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
original = raw_config()
path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
service = AuthService(path, account="write")
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
"code": 0, "data": {"isLogin": True, "mid": 99, "uname": "writer"}
}
assert service.finish(service._load_raw(), NEW) == 0
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["bilibili"]["read"] == original["bilibili"]["read"]
assert saved["bilibili"]["write"]["mid"] == 99
assert saved["bilibili"]["write"]["csrf"] == "newcsrf"
assert cookie_value(saved["bilibili"]["write"]["cookie"], "buvid3") == "WRITE-B3"
assert path.with_name("config.json.bak").exists()
def test_ensure_device_cookies():
called = {"n": 0}
def fake_get_json(cfg, url, auth=False):
called["n"] += 1
return {"code": 0, "data": {"b_3": "NEW-B3", "b_4": "NEW-B4"}}
orig = f.get_json
f.get_json = fake_get_json
try:
cfg = {"cookie": "SESSDATA=x; bili_jct=y"}
added = f.ensure_device_cookies(cfg)
assert "buvid3" in added and "buvid4" in added and "_uuid" in added
assert f.cookie_value(cfg["cookie"], "buvid3") == "NEW-B3"
assert f.cookie_value(cfg["cookie"], "buvid4") == "NEW-B4"
assert f.cookie_value(cfg["cookie"], "_uuid").endswith("infoc")
# 已有设备字段时不应调用 spi,也不应改动
called["n"] = 0
cfg2 = {"cookie": OLD}
added2 = f.ensure_device_cookies(cfg2)
assert added2 == [] and called["n"] == 0
assert cfg2["cookie"] == OLD
finally:
f.get_json = orig
def test_finish_auth_write_and_rollback():
import json
import tempfile
new = {"SESSDATA": "new%2C1800000000%2Cxyz", "bili_jct": "newcsrf",
"DedeUserID": "28802351", "DedeUserID__ckMd5": "newmd5"}
cfg = {"mid": 28802351, "csrf": "oldcsrf", "cookie": OLD,
"overrides": {}, "sleep": {}}
old_raw = json.dumps(cfg, ensure_ascii=False, indent=2)
orig = (f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies)
def fake_nav_ok(c):
return {"code": 0, "data": {"isLogin": True, "uname": "tester", "mid": 28802351}}
def fake_nav_bad(c):
return {"code": -101, "message": "账号未登录", "data": {"isLogin": False}}
def fake_device(c):
return []
try:
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "config.json")
f.CONFIG_PATH, f.CONFIG_BACKUP = path, path + ".bak"
f.ensure_device_cookies = fake_device
with open(path, "w", encoding="utf-8") as fh:
fh.write(old_raw)
# 成功:写入新身份字段,设备字段不变,并生成备份
f.fetch_nav = fake_nav_ok
assert f.finish_auth(cfg, dict(new)) == 0
with open(path, encoding="utf-8") as fh:
saved = json.load(fh)
assert "newcsrf" == saved["csrf"]
assert f.cookie_value(saved["cookie"], "buvid3") == "OLD-B3"
assert f.cookie_value(saved["cookie"], "SESSDATA") == "new%2C1800000000%2Cxyz"
assert os.path.exists(path + ".bak")
# 失败:用上一份备份回滚
before = open(path, encoding="utf-8").read()
cfg2 = dict(saved)
f.fetch_nav = fake_nav_bad
assert f.finish_auth(cfg2, {"SESSDATA": "x%2C1%2Cy", "bili_jct": "z"}) == 4
after = open(path, encoding="utf-8").read()
assert after == before
finally:
f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies = orig
before = path.read_text(encoding="utf-8")
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
"code": -101, "data": {"isLogin": False}
}
assert service.finish(service._load_raw(), NEW) == 4
assert path.read_text(encoding="utf-8") == before
def test_qr_png():
import tempfile
path = os.path.join(tempfile.gettempdir(), "favsort-test-qr.png")
try:
f.write_qr_png("https://example.com/login?key=abc&from=test", path)
with open(path, "rb") as fh:
data = fh.read()
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert len(data) > 500
finally:
if os.path.exists(path):
os.remove(path)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "qr.png"
write_qr_png("https://example.com/login?key=abc", path)
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
def main():
test_parse_and_merge()
test_set_cookie_and_url_fallback()
test_expiry()
test_ensure_device_cookies()
test_finish_auth_write_and_rollback()
test_cookie_helpers()
test_finish_updates_only_selected_account_and_rolls_back()
test_qr_png()
print("OK: auth 纯函数测试全部通过")
print("OK: 双账号 auth Cookie 合并、写回隔离与回滚测试全部通过")
if __name__ == "__main__":
+44 -4
View File
@@ -22,9 +22,10 @@ import reaction_board.cli as cli # noqa: E402
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
path = Path(tmp) / "config.json"
path.write_text(json.dumps({
"mid": 1,
"csrf": csrf,
"cookie": cookie,
"bilibili": {
"read": {"mid": 1, "csrf": csrf, "cookie": cookie},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
@@ -112,6 +113,37 @@ def test_auth_check_without_cookie():
assert "未配置 cookie" in payload["message"]
def test_auth_check_all_and_auth_dispatch():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="")
code, output = run_cli(["auth-check", "--account", "all", "--config", str(path)])
assert code == 1
payload = json.loads(output)
assert payload["ok"] is False
assert [item["account"] for item in payload["accounts"]] == ["read", "write"]
original = cli.AuthService
calls = []
class FakeAuthService:
def __init__(self, path, *, account, logger):
calls.append((str(path), account))
def run(self, *, timeout, no_browser):
assert timeout == 9 and no_browser is True
return 7
cli.AuthService = FakeAuthService
try:
code, _ = run_cli([
"auth", "--account", "write", "--timeout", "9", "--no-browser", "--config", str(path),
])
finally:
cli.AuthService = original
assert code == 7
assert calls == [(str(path), "write")]
def test_run_once_offline():
class FakeClient:
def check_login(self):
@@ -125,7 +157,13 @@ def test_run_once_offline():
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
original = cli._client
cli._client = lambda *args, **kwargs: FakeClient()
selected = []
def fake_client(credentials, *args, **kwargs):
selected.append(credentials)
return FakeClient()
cli._client = fake_client
try:
code, output = run_cli(["run", "--once", "--config", str(path)])
finally:
@@ -133,6 +171,7 @@ def test_run_once_offline():
assert code == 0
assert '"name": "folder_sync"' in output
assert '"name": "snapshot"' in output
assert selected and all(credentials.mid == 1 for credentials in selected)
def main_test():
@@ -141,6 +180,7 @@ def main_test():
test_sync_requires_auth_without_network()
test_snapshot_offline_empty_db()
test_auth_check_without_cookie()
test_auth_check_all_and_auth_dispatch()
test_run_once_offline()
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
+65 -140
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
"""双账号配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
import json
import os
@@ -13,24 +13,22 @@ sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigError, ConfigStore, load_config # noqa: E402
COOKIE = "SESSDATA=secret%2C1800000000%2Cabc; bili_jct=csrf-secret; buvid3=dev"
def write(path: Path, data) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def bump_mtime(path: Path) -> None:
"""保证 mtime 严格大于上一次,避免 Windows 时间精度导致热加载测试偶发失败。"""
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
os.utime(path, ns=(target, target))
def base_legacy() -> dict:
def base_config() -> dict:
return {
"mid": 28802351,
"csrf": "csrf-secret",
"cookie": COOKIE,
"bilibili": {
"read": {"mid": 11, "csrf": "read-csrf", "cookie": "read-cookie"},
"write": {"mid": 22, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"overrides": {"1": {"mid": 2, "name": "x"}},
"sleep": {"min": 1},
"folders": [
@@ -42,168 +40,95 @@ def base_legacy() -> dict:
}
def test_legacy_flat_mapping():
def test_dual_accounts_and_env_override():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, base_legacy())
cfg = load_config(path, env={})
assert cfg.bilibili.mid == 28802351
assert cfg.bilibili.csrf == "csrf-secret"
assert cfg.bilibili.cookie == COOKIE
assert cfg.storage.database_path == Path(tmp) / "data" / "app.db"
assert cfg.storage.backup_dir == Path(tmp) / "data" / "backup"
assert cfg.schedule.snapshot_at == "00:01"
assert [f.media_id for f in cfg.folders] == [4451053851, 3700023351]
assert cfg.track_fans == (37754047,)
assert cfg.up_blacklist_seed == (1, 2) # 去重
assert cfg.raw["overrides"] # 旧字段仍保留给 favsort
assert set(cfg.heat_weights) == {
"view", "like", "coin", "favorite", "share", "reply", "danmaku"
}
def test_nested_and_env_override():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, {
"bilibili": {"mid": 1, "csrf": "old", "cookie": "old-cookie"},
"storage": {"database_path": "var/app.db", "backup_dir": "var/bak"},
"folders": [],
})
write(path, base_config())
env = {
"BILI_MID": "999",
"BILI_CSRF": "env-csrf",
"BILI_COOKIE": "env-cookie",
"TG_BOT_TOKEN": "123456:abcdefghijklmnopqrstuvwxyzABCDE",
"TG_CHAT_ID": "42",
"DASHBOARD_AUTH_TOKEN": "web-token",
"BILI_READ_MID": "101", "BILI_READ_CSRF": "env-read", "BILI_READ_COOKIE": "env-read-cookie",
"BILI_WRITE_MID": "202", "BILI_WRITE_CSRF": "env-write", "BILI_WRITE_COOKIE": "env-write-cookie",
"DATABASE_PATH": "custom.db",
}
cfg = load_config(path, env=env)
assert cfg.bilibili.mid == 999
assert cfg.bilibili.csrf == "env-csrf"
assert cfg.bilibili.cookie == "env-cookie"
assert cfg.telegram.chat_id == 42
assert cfg.telegram.bot_token.startswith("123456:")
assert cfg.web.auth_token == "web-token"
assert (cfg.bilibili.read.mid, cfg.bilibili.read.csrf, cfg.bilibili.read.cookie) == (101, "env-read", "env-read-cookie")
assert (cfg.bilibili.write.mid, cfg.bilibili.write.csrf, cfg.bilibili.write.cookie) == (202, "env-write", "env-write-cookie")
assert cfg.storage.database_path == Path(tmp) / "custom.db"
# 备份目录仍按配置文件目录解析
assert cfg.storage.backup_dir == Path(tmp) / "var" / "bak"
assert cfg.up_blacklist_seed == (1, 2)
def test_absolute_paths_kept():
with tempfile.TemporaryDirectory() as tmp:
db = str(Path(tmp) / "abs" / "app.db")
path = Path(tmp) / "config.json"
write(path, {
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
"storage": {"database_path": db, "backup_dir": str(Path(tmp) / "b")},
"folders": [],
})
cfg = load_config(path, env={})
assert cfg.storage.database_path == Path(db)
def test_invalid_configs():
def test_flat_and_missing_account_rejected():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
cases = [
({"folders": [{"year": 2025, "media_id": 1},
{"year": 2026, "media_id": 1}]}, "media_id 重复"),
({"folders": [{"year": 2025, "media_id": 1},
{"year": 2025, "media_id": 2}]}, "year 重复"),
({"folders": [{"year": 2025, "media_id": 0}]}, "不能小于 1"),
({"schedule": {"snapshot_at": "24:00"}}, "snapshot_at"),
({"schedule": {"snapshot_at": "1:01"}}, "snapshot_at"),
({"notify": {"max_items_per_message": 0}}, "max_items_per_message"),
({"heat_weights": {"view": -1}}, "heat_weights.view"),
({"classify": {"require_title": ""}}, "require_title"),
]
base = {
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
"folders": [],
}
for patch, needle in cases:
data = json.loads(json.dumps(base))
data.update(patch)
write(path, data)
try:
load_config(path, env={})
except ConfigError as exc:
assert needle in str(exc), (needle, str(exc))
else:
raise AssertionError(f"应当失败: {patch}")
write(path, {"mid": 1, "csrf": "x", "cookie": "y", "folders": []})
try:
load_config(path, env={})
except ConfigError as exc:
assert "bilibili.read" in str(exc)
else:
raise AssertionError("flat 格式必须拒绝")
write(path, {"bilibili": {"read": {}}, "folders": []})
try:
load_config(path, env={})
except ConfigError as exc:
assert "bilibili.write" in str(exc)
else:
raise AssertionError("缺少 write 必须拒绝")
def test_require_auth():
def test_validation_require_read_auth_and_hot_reload():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, {"bilibili": {"mid": 1, "csrf": "", "cookie": ""}, "folders": []})
data = base_config()
data["bilibili"]["read"].update(cookie="", csrf="")
write(path, data)
load_config(path, env={}, require_auth=False)
try:
load_config(path, env={}, require_auth=True)
except ConfigError as exc:
assert "cookie" in str(exc)
assert "bilibili.read.cookie" in str(exc)
else:
raise AssertionError("require_auth 应拒绝空凭据")
raise AssertionError("read 空凭据必须拒绝")
def test_env_int_invalid():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, base_legacy())
try:
load_config(path, env={"BILI_MID": "abc"})
except ConfigError as exc:
assert "BILI_MID" in str(exc)
else:
raise AssertionError("非法整数环境变量应失败")
def test_hot_reload_keeps_last_good_config():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, base_legacy())
write(path, base_config())
store = ConfigStore(path, env={})
assert store.get().folders[0].title == "2025"
data = base_legacy()
data["folders"][0]["title"] = "2025-新"
data = base_config()
data["folders"][0]["title"] = "2025-new"
write(path, data)
bump_mtime(path)
assert store.get().folders[0].title == "2025-新"
assert store.last_error is None
path.write_text("{not json", encoding="utf-8")
assert store.get().folders[0].title == "2025-new"
path.write_text("{bad", encoding="utf-8")
bump_mtime(path)
assert store.get().folders[0].title == "2025-新"
assert store.last_error is not None
assert store.get().folders[0].title == "2025-new"
assert store.last_error
def test_invalid_values():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
data = base_config()
data["bilibili"]["write"]["mid"] = "bad"
write(path, data)
bump_mtime(path)
assert store.get().folders[0].title == "2025-新"
assert store.last_error is None
def test_missing_file():
try:
load_config(Path(tempfile.gettempdir()) / "no-such-bili-fav-config.json", env={})
except ConfigError as exc:
assert "缺少配置文件" in str(exc)
else:
raise AssertionError("缺失配置应失败")
try:
load_config(path, env={})
except ConfigError as exc:
assert "bilibili.write.mid" in str(exc)
else:
raise AssertionError("非法 mid 必须拒绝")
write(path, base_config())
try:
load_config(path, env={"BILI_READ_MID": "bad"})
except ConfigError as exc:
assert "BILI_READ_MID" in str(exc)
else:
raise AssertionError("非法环境变量必须拒绝")
def main():
test_legacy_flat_mapping()
test_nested_and_env_override()
test_absolute_paths_kept()
test_invalid_configs()
test_require_auth()
test_env_int_invalid()
test_hot_reload_keeps_last_good_config()
test_missing_file()
print("OK: 配置加载、校验与热加载测试全部通过")
test_dual_accounts_and_env_override()
test_flat_and_missing_account_rejected()
test_validation_require_read_auth_and_hot_reload()
test_invalid_values()
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖与热加载测试全部通过")
if __name__ == "__main__":
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""favsort 写账号配置隔离测试: uv run python tests/test_favsort_config.py"""
import json
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
import favsort # noqa: E402
def test_loads_write_account_only():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(json.dumps({
"bilibili": {
"read": {"mid": 11, "csrf": "read-csrf", "cookie": "read-cookie"},
"write": {"mid": 22, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"overrides": {"1": {"mid": 2, "name": "UP"}},
}), encoding="utf-8")
old = favsort.CONFIG_PATH
favsort.CONFIG_PATH = str(path)
try:
cfg = favsort.load_config()
finally:
favsort.CONFIG_PATH = old
assert cfg["mid"] == 22
assert cfg["csrf"] == "write-csrf"
assert cfg["cookie"] == "write-cookie"
assert cfg["overrides"] == {"1": {"mid": 2, "name": "UP"}}
def test_rejects_flat_config():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
path.write_text(json.dumps({"mid": 1, "csrf": "x", "cookie": "y"}), encoding="utf-8")
old = favsort.CONFIG_PATH
favsort.CONFIG_PATH = str(path)
try:
try:
favsort.load_config()
except SystemExit as exc:
assert "bilibili.write" in str(exc)
else:
raise AssertionError("flat config 必须拒绝")
finally:
favsort.CONFIG_PATH = old
def main():
test_loads_write_account_only()
test_rejects_flat_config()
print("OK: favsort 仅使用 bilibili.write 配置测试全部通过")
if __name__ == "__main__":
main()
+4 -3
View File
@@ -74,9 +74,10 @@ class FakeFavTransport:
def write_config(tmp, folders):
data = {
"mid": 28802351,
"csrf": "csrf-secret",
"cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device",
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf-secret", "cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device"},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
+4 -3
View File
@@ -75,9 +75,10 @@ def make_service(tmp, transport, *, track_fans=(37754047,), logger=None,
retries=3, cookie="SESSDATA=secret; buvid3=device"):
path = Path(tmp) / "config.json"
path.write_text(json.dumps({
"mid": 28802351,
"csrf": "csrf",
"cookie": cookie,
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf", "cookie": cookie},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),