feat(auth): separate read and write bilibili accounts
This commit is contained in:
1 parent
5c2ab7b187
commit
178ab64b18
17 files changed
+671
-707
No files matched your search
+21
-324
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user