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

+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,