Implement P0 of the reaction dashboard: config loading with hot reload, SQLite migrations with backups, single-writer queue, read-only Bilibili fav client, folder import/diff sync, invalid-resource handling, blacklist seed, scheduler, and JSON redacted logging. Includes offline unit and transport-level contract tests; all existing and new tests pass.
945 lines
35 KiB
Python
945 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
bili-fav / favsort —— B 站收藏夹批量重排序工具
|
||
|
||
排序规则
|
||
1) 同一个 upper 的视频必须连续(聚成一组),组内按发布时间倒序(并列按 avid 升序)
|
||
2) 组间按"该 upper 在本收藏夹中最早视频的发布时间"倒序(并列按该组最小 avid 升序)
|
||
3) 失效/异常资源置于末尾,保持原相对顺序
|
||
4) config.json 里的 overrides 可把指定视频强制归入另一个 upper(代发等场景)
|
||
|
||
指令
|
||
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>
|
||
python favsort.py replan <media_id>
|
||
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。
|
||
"""
|
||
|
||
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 稳定)
|
||
API = {
|
||
"ids": "{base}/x/v3/fav/resource/ids?media_id={mid}&platform=web",
|
||
"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,
|
||
"max": 7.5,
|
||
"long_every_min": 20,
|
||
"long_every_max": 40,
|
||
"long_min": 30,
|
||
"long_max": 90,
|
||
"retry_wait": 45,
|
||
},
|
||
}
|
||
|
||
|
||
def load_config(require_auth=True):
|
||
if not os.path.exists(CONFIG_PATH):
|
||
raise SystemExit(
|
||
f"缺少 {CONFIG_PATH}\n"
|
||
f"请复制 config.example.json 为 config.json 并填入 cookie / csrf / mid。"
|
||
)
|
||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
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)")
|
||
return cfg
|
||
|
||
|
||
# ---------------------------------------------------------------- http
|
||
|
||
def _get_json(cfg, url, auth):
|
||
req = urllib.request.Request(url, headers=make_headers(cfg, auth=auth))
|
||
with urllib.request.urlopen(req, timeout=30) as r:
|
||
return json.loads(r.read().decode("utf-8", "replace"))
|
||
|
||
|
||
def get_json(cfg, url, auth=False):
|
||
"""只读请求:默认用去掉账号身份的 cookie。
|
||
|
||
万一匿名请求被拒(HTTP 错误,或 code != 0),自动带完整 cookie 重试一次,
|
||
保证功能不会因为剥离身份而回退。
|
||
"""
|
||
if auth:
|
||
return _get_json(cfg, url, auth=True)
|
||
try:
|
||
r = _get_json(cfg, url, auth=False)
|
||
if r.get("code") == 0:
|
||
return r
|
||
except Exception: # noqa: BLE001 网络错误 / 412 风控都走回退
|
||
pass
|
||
return _get_json(cfg, url, auth=True)
|
||
|
||
|
||
def post_form(cfg, url, data):
|
||
headers = make_headers(cfg, auth=True)
|
||
headers["content-type"] = "application/x-www-form-urlencoded"
|
||
req = urllib.request.Request(
|
||
url, data=urllib.parse.urlencode(data).encode(), headers=headers, method="POST"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as r:
|
||
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:
|
||
raise SystemExit(f"ids 失败: code={r.get('code')} msg={r.get('message')}")
|
||
return r.get("data") or []
|
||
|
||
|
||
def fetch_infos(cfg, media_id, folder_mid, id_list):
|
||
items, calls = [], 0
|
||
for i in range(0, len(id_list), CHUNK):
|
||
chunk = id_list[i:i + CHUNK]
|
||
res = ",".join(f"{x['id']}:{x['type']}" for x in chunk)
|
||
url = API["infos"].format(
|
||
base=BASE, res=urllib.parse.quote(res), fmid=folder_mid, mid=media_id
|
||
)
|
||
r = get_json(cfg, url)
|
||
calls += 1
|
||
if r.get("code") != 0:
|
||
raise SystemExit(f"infos 失败 @{i}: code={r.get('code')} msg={r.get('message')}")
|
||
m = {g["id"]: g for g in (r.get("data") or [])}
|
||
for c in chunk:
|
||
g = m.get(c["id"]) or {}
|
||
up = g.get("upper") or {}
|
||
items.append({
|
||
"avid": c["id"],
|
||
"type": c["type"],
|
||
"title": g.get("title") or "(缺失)",
|
||
"upper_mid": up.get("mid"),
|
||
"upper_name": up.get("name") or "(未知UP)",
|
||
"pubtime": g.get("pubtime") or g.get("ctime") or 0,
|
||
"attr": g.get("attr", 0),
|
||
"found": bool(g),
|
||
})
|
||
return items, calls
|
||
|
||
|
||
# ---------------------------------------------------------------- core
|
||
|
||
def build_target(items, overrides):
|
||
applied = []
|
||
for v in items:
|
||
w = dict(v)
|
||
o = overrides.get(str(v["avid"])) or overrides.get(v["avid"])
|
||
if o:
|
||
w["upper_mid"] = o["mid"]
|
||
w["upper_name"] = o["name"]
|
||
w["overridden"] = True
|
||
applied.append(w)
|
||
|
||
invalid = [x for x in applied if (not x["found"]) or x["attr"] != 0 or not x["upper_mid"]]
|
||
valid = [x for x in applied if x not in invalid]
|
||
|
||
groups = {}
|
||
for v in valid:
|
||
g = groups.setdefault(v["upper_mid"], {"mid": v["upper_mid"], "name": v["upper_name"], "vids": []})
|
||
g["vids"].append(v)
|
||
garr = list(groups.values())
|
||
for g in garr:
|
||
g["vids"].sort(key=lambda v: (-v["pubtime"], v["avid"]))
|
||
g["key"] = min(v["pubtime"] for v in g["vids"])
|
||
g["min_avid"] = min(v["avid"] for v in g["vids"])
|
||
garr.sort(key=lambda g: (-g["key"], g["min_avid"]))
|
||
|
||
target = [v for g in garr for v in g["vids"]] + invalid
|
||
return applied, garr, target
|
||
|
||
|
||
def lcs_keep(cur, tgt):
|
||
n = len(cur)
|
||
dp = [[0] * (n + 1) for _ in range(n + 1)]
|
||
for i in range(n - 1, -1, -1):
|
||
for j in range(n - 1, -1, -1):
|
||
dp[i][j] = dp[i + 1][j + 1] + 1 if cur[i] == tgt[j] else max(dp[i + 1][j], dp[i][j + 1])
|
||
i = j = 0
|
||
kept = set()
|
||
while i < n and j < n:
|
||
if cur[i] == tgt[j]:
|
||
kept.add(cur[i]); i += 1; j += 1
|
||
elif dp[i + 1][j] >= dp[i][j + 1]:
|
||
i += 1
|
||
else:
|
||
j += 1
|
||
return kept
|
||
|
||
|
||
def _one_pass(work, tgt):
|
||
"""单轮:保留 LCS 里的元素不动,其余元素逐个插到"目标前驱"之后。
|
||
|
||
原地修改 work,返回本轮动作。返回后 work 会非常接近 tgt(通常只差 0~5 个位置),
|
||
由 build_moves 多跑几轮收敛。
|
||
"""
|
||
kept = lcs_keep(work, tgt)
|
||
pos = {a: i for i, a in enumerate(tgt)}
|
||
moves = []
|
||
|
||
# 首位:API 只能"插到某人之后",要占住第 1 位得走两步
|
||
if work[0] != tgt[0]:
|
||
a = tgt[0]
|
||
first = work[0]
|
||
f = work.index(a)
|
||
if f != 1:
|
||
work.pop(f)
|
||
work.insert(1, a)
|
||
moves.append({"moved": a, "pred": first, "from": f + 1, "to": 2})
|
||
k = pos[first]
|
||
work.remove(first)
|
||
pred = work[k - 1]
|
||
work.insert(k, first)
|
||
moves.append({"moved": first, "pred": pred, "from": 1, "to": k + 1})
|
||
kept.discard(a)
|
||
kept.discard(first)
|
||
|
||
for t in range(1, len(tgt)):
|
||
a = tgt[t]
|
||
if work[t] == a or a in kept:
|
||
continue
|
||
f = work.index(a)
|
||
work.pop(f)
|
||
i = work.index(tgt[t - 1])
|
||
work.insert(i + 1, a)
|
||
moves.append({"moved": a, "pred": tgt[t - 1], "from": f + 1, "to": i + 2})
|
||
kept.discard(a)
|
||
return moves
|
||
|
||
|
||
def build_moves(cur, tgt):
|
||
"""生成单步移动脚本。
|
||
|
||
服务端语义(实测):sort=A:2:B:2 => 把 B 插到 A 之后(B 可以往前也可以往后)。
|
||
最少步数 ≈ n - LCS(cur, tgt):保留最长公共子序列不动,只搬其余元素。
|
||
由于"保留元素"需要靠其它元素被搬走才会滑到正确位置,单轮往往差几个位置,
|
||
多跑一两轮即可收敛(实测 658 条约 100 步,正好等于理论下界)。
|
||
"""
|
||
work = list(cur)
|
||
moves = []
|
||
for _ in range(20):
|
||
if work == tgt:
|
||
break
|
||
part = _one_pass(work, tgt)
|
||
if not part:
|
||
break
|
||
moves.extend(part)
|
||
assert work == tgt, "内部错误:生成的动作未能到达目标顺序"
|
||
return moves
|
||
|
||
|
||
def apply_move(work, m):
|
||
work = list(work)
|
||
work.remove(m["moved"])
|
||
i = work.index(m["pred"])
|
||
work.insert(i + 1, m["moved"])
|
||
return work
|
||
|
||
|
||
# ---------------------------------------------------------------- io
|
||
|
||
def ensure_data_dir():
|
||
os.makedirs(DATA_DIR, exist_ok=True)
|
||
|
||
|
||
def plan_path(media_id):
|
||
return os.path.join(DATA_DIR, f"plan-{media_id}.json")
|
||
|
||
|
||
def journal_path(media_id):
|
||
return os.path.join(DATA_DIR, f"journal-{media_id}.jsonl")
|
||
|
||
|
||
def load_plan(media_id):
|
||
p = plan_path(media_id)
|
||
if not os.path.exists(p):
|
||
raise SystemExit(f"找不到计划文件 {p},请先运行: python favsort.py plan {media_id}")
|
||
with open(p, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
|
||
# ---------------------------------------------------------------- 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),
|
||
# 因此这里必须带完整 cookie(仍然只是 GET,不会改动任何数据)。
|
||
r = get_json(cfg, API["folders"].format(base=BASE, mid=cfg["mid"]), auth=True)
|
||
if r.get("code") != 0:
|
||
raise SystemExit(f"获取收藏夹列表失败: {r.get('code')} {r.get('message')}")
|
||
lst = (r.get("data") or {}).get("list") or []
|
||
print(f"共 {len(lst)} 个自建收藏夹(mid={cfg['mid']})")
|
||
for f in sorted(lst, key=lambda x: -x.get("media_count", 0)):
|
||
print(f" {f['id']:<12} {f.get('media_count', 0):>5} 条 {f['title']}")
|
||
|
||
|
||
def cmd_plan(cfg, args):
|
||
media_id = args.media_id
|
||
folder_mid = args.folder_mid or cfg["mid"]
|
||
ensure_data_dir()
|
||
|
||
print(f"[plan] 拉取资源 id (media_id={media_id}) ...")
|
||
id_list = fetch_ids(cfg, media_id)
|
||
print(f"[plan] {len(id_list)} 条资源,分批拉取详情 ...")
|
||
items, calls = fetch_infos(cfg, media_id, folder_mid, id_list)
|
||
|
||
applied, garr, target = build_target(items, cfg["overrides"])
|
||
cur = [v["avid"] for v in applied]
|
||
tgt = [v["avid"] for v in target]
|
||
kept = lcs_keep(cur, tgt)
|
||
moves = build_moves(cur, tgt)
|
||
|
||
plan = {
|
||
"media_id": media_id,
|
||
"folder_mid": folder_mid,
|
||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"requests": {"ids": 1, "infos": calls},
|
||
"n": len(cur),
|
||
"groups": len(garr),
|
||
"lcs": len(kept),
|
||
"current_order": cur,
|
||
"target_order": tgt,
|
||
"moves": moves,
|
||
"titles": {
|
||
v["avid"]: {
|
||
"title": v["title"],
|
||
"upper": v["upper_name"],
|
||
"pubtime": v["pubtime"],
|
||
"overridden": v.get("overridden", False),
|
||
}
|
||
for v in applied
|
||
},
|
||
"group_summary": [
|
||
{
|
||
"name": g["name"],
|
||
"mid": g["mid"],
|
||
"key": g["key"],
|
||
"count": len(g["vids"]),
|
||
"newest": max(v["pubtime"] for v in g["vids"]),
|
||
}
|
||
for g in garr
|
||
],
|
||
"unchanged": cur == tgt,
|
||
}
|
||
with open(plan_path(media_id), "w", encoding="utf-8") as f:
|
||
json.dump(plan, f, ensure_ascii=False, indent=1)
|
||
|
||
print(f"[plan] n={plan['n']} 分组={plan['groups']} 不动={plan['lcs']} "
|
||
f"需移动={len(moves)} 已是最优={plan['unchanged']}")
|
||
print(f"[plan] 已保存 -> {plan_path(media_id)}")
|
||
if moves and args.verbose:
|
||
local_titles = {v["avid"]: v["title"] for v in applied}
|
||
for i, m in enumerate(moves, 1):
|
||
print(f" {i:>4}. {local_titles[m['moved']][:44]:<45} {m['from']:>4} -> {m['to']:<4}")
|
||
if moves and not args.verbose:
|
||
print("[plan] 加 --verbose 可查看完整移动脚本")
|
||
|
||
|
||
def cmd_show(cfg, args):
|
||
plan = load_plan(args.media_id)
|
||
print(f"media={plan['media_id']} n={plan['n']} 分组={plan['groups']} 移动={len(plan['moves'])}")
|
||
print(f"plan 生成于 {plan.get('created_at')},执行于 {plan.get('applied_at', '未执行')}")
|
||
for i, m in enumerate(plan["moves"], 1):
|
||
t = plan["titles"][str(m["moved"])]
|
||
print(f" {i:>4}. {t['title'][:44]:<45} {m['from']:>4} -> {m['to']:<4}")
|
||
|
||
|
||
def cmd_run(cfg, args):
|
||
media_id = args.media_id
|
||
plan = load_plan(media_id)
|
||
target = plan["target_order"]
|
||
titles = plan["titles"]
|
||
sc = cfg["sleep"]
|
||
verify_every = args.verify_every
|
||
|
||
print("[run] 读取当前顺序 ...")
|
||
actual = [x["id"] for x in fetch_ids(cfg, media_id)]
|
||
if actual == target:
|
||
print("[run] 顺序已与目标一致,无需操作")
|
||
return 0
|
||
|
||
if set(actual) != set(target):
|
||
added = set(actual) - set(target)
|
||
removed = set(target) - set(actual)
|
||
print(f"[run] 收藏夹内容已变化:计划 {len(target)} 条 / 现在 {len(actual)} 条,"
|
||
f"新增 {len(added)} 条、移除 {len(removed)} 条")
|
||
print("[run] 目标序已过期,请先重新生成计划: python favsort.py plan %d" % media_id)
|
||
return 6
|
||
|
||
moves = build_moves(actual, target)
|
||
if any(m["pred"] is None for m in moves):
|
||
print("[run] 存在需移动到第 1 位(无前驱锚点)的步骤,当前版本不支持")
|
||
return 5
|
||
|
||
print(f"[run] 从当前状态重算: {len(moves)} 步 / 目标 {len(target)} 条")
|
||
for i, m in enumerate(moves, 1):
|
||
t = titles[str(m["moved"])]
|
||
print(f" {i:>4}. {t['title'][:40]:<41} {m['from']:>4} -> {m['to']:<4}")
|
||
|
||
open(journal_path(media_id), "w", encoding="utf-8").close()
|
||
work = list(actual)
|
||
total = len(moves)
|
||
long_after = random.randint(sc["long_every_min"], sc["long_every_max"])
|
||
|
||
for i, m in enumerate(moves, 1):
|
||
body = {"media_id": media_id, "sort": f"{m['pred']}:2:{m['moved']}:2", "csrf": cfg["csrf"]}
|
||
print(f"[run] {i}/{total} {titles[str(m['moved'])]['title'][:30]} "
|
||
f"-> after {m['pred']}", end=" ", flush=True)
|
||
resp = post_form(cfg, API["sort"].format(base=BASE), body)
|
||
code = resp.get("code")
|
||
print(f"code={code}")
|
||
if code != 0:
|
||
wait = sc["retry_wait"]
|
||
print(f"[run] 返回非 0,冷却 {wait}s 后重试一次")
|
||
time.sleep(wait)
|
||
resp = post_form(cfg, API["sort"].format(base=BASE), body)
|
||
code = resp.get("code")
|
||
print(f"[run] retry code={code}")
|
||
if code != 0:
|
||
print("[run] 重试仍失败,中止(可用 run 续跑)")
|
||
return 2
|
||
|
||
work = apply_move(work, m)
|
||
with open(journal_path(media_id), "a", encoding="utf-8") as f:
|
||
f.write(json.dumps({
|
||
"i": i, "moved": m["moved"], "pred": m["pred"], "to": m["to"],
|
||
"code": code, "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}, ensure_ascii=False) + "\n")
|
||
|
||
time.sleep(random.uniform(sc["min"], sc["max"]))
|
||
|
||
if i <= 3 or i % verify_every == 0 or i == total:
|
||
now = [x["id"] for x in fetch_ids(cfg, media_id)]
|
||
if now != work:
|
||
print(f"[verify] 第 {i} 步后与模拟不符,停止")
|
||
for k in range(min(len(now), len(work))):
|
||
if now[k] != work[k]:
|
||
print(f" 首个差异 idx={k} 实际={now[k]} 预期={work[k]}")
|
||
break
|
||
return 3
|
||
print(f"[verify] 第 {i} 步 OK")
|
||
time.sleep(random.uniform(sc["min"], sc["max"]))
|
||
|
||
if i % long_after == 0 and i != total:
|
||
pause = random.uniform(sc["long_min"], sc["long_max"])
|
||
print(f"[run] 长冷却 {pause:.0f}s")
|
||
time.sleep(pause)
|
||
long_after = random.randint(sc["long_every_min"], sc["long_every_max"])
|
||
|
||
final = [x["id"] for x in fetch_ids(cfg, media_id)]
|
||
ok = final == target
|
||
print(f"[done] 最终顺序 {'与目标完全一致 ✅' if ok else '与目标不一致 ❌'}")
|
||
plan["moves"] = moves
|
||
plan["applied_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
with open(plan_path(media_id), "w", encoding="utf-8") as f:
|
||
json.dump(plan, f, ensure_ascii=False, indent=1)
|
||
if not ok:
|
||
for k in range(min(len(final), len(target))):
|
||
if final[k] != target[k]:
|
||
print(f" 首个差异 idx={k} 实际={final[k]} 目标={target[k]}")
|
||
break
|
||
return 4
|
||
return 0
|
||
|
||
|
||
def cmd_replan(cfg, args):
|
||
"""不联网:用 plan 里保存的 current_order / target_order 重新生成移动脚本。"""
|
||
media_id = args.media_id
|
||
plan = load_plan(media_id)
|
||
cur, tgt = plan["current_order"], plan["target_order"]
|
||
if cur == tgt:
|
||
print("[replan] 原序即目标序,无需移动")
|
||
return 0
|
||
kept = lcs_keep(cur, tgt)
|
||
moves = build_moves(cur, tgt)
|
||
plan["moves"] = moves
|
||
plan["lcs"] = len(kept)
|
||
plan["replanned_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
with open(plan_path(media_id), "w", encoding="utf-8") as f:
|
||
json.dump(plan, f, ensure_ascii=False, indent=1)
|
||
print(f"[replan] n={plan['n']} 不动={len(kept)} 理论下界={plan['n'] - len(kept)} "
|
||
f"实际步数={len(moves)}")
|
||
titles = plan["titles"]
|
||
for i, m in enumerate(moves, 1):
|
||
t = titles[str(m["moved"])]
|
||
print(f" {i:>4}. {t['title'][:40]:<41} {m['from']:>4} -> {m['to']:<4}")
|
||
return 0
|
||
|
||
|
||
def cmd_verify(cfg, args):
|
||
plan = load_plan(args.media_id)
|
||
target = plan["target_order"]
|
||
cur = [x["id"] for x in fetch_ids(cfg, args.media_id)]
|
||
if cur == target:
|
||
print(f"[verify] 与目标顺序完全一致 ✅(n={len(cur)})")
|
||
return 0
|
||
diffs = [i for i in range(min(len(cur), len(target))) if cur[i] != target[i]]
|
||
print(f"[verify] 不一致 ❌ 差异位置 {len(diffs)} 处,前 5 处:")
|
||
for i in diffs[:5]:
|
||
t = plan["titles"]
|
||
print(f" idx={i} 当前={t.get(str(cur[i]), {}).get('title', '?')[:36]}")
|
||
print(f" 目标={t.get(str(target[i]), {}).get('title', '?')[:36]}")
|
||
return 1
|
||
|
||
|
||
# ---------------------------------------------------------------- main
|
||
|
||
COMMAND_HELP = {
|
||
"auth": "扫码登录并更新 config.json 里失效的身份 cookie(保留设备指纹)",
|
||
"folders": "列出自己(config.mid)的自建收藏夹及其 media_id",
|
||
"plan": "拉取收藏夹数据并生成排序计划(只读,不改动收藏夹)",
|
||
"show": "查看已生成的计划与完整移动脚本(离线)",
|
||
"replan": "离线重算移动脚本(不联网,不改目标顺序)",
|
||
"run": "按计划逐步执行移动(写操作,每步一个 sort 请求)",
|
||
"verify": "对比当前实际顺序与计划目标",
|
||
"help": "列出所有子命令与可用选项",
|
||
}
|
||
|
||
|
||
def build_parser():
|
||
p = argparse.ArgumentParser(
|
||
prog="favsort",
|
||
description="B 站收藏夹批量重排序:同 UP 聚在一起,组间按该 UP 最早投稿时间倒序。",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
sub = p.add_subparsers(dest="cmd", title="子命令", metavar="<command>")
|
||
|
||
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)")
|
||
pp.add_argument("--verbose", "-v", action="store_true", help="打印完整移动脚本")
|
||
|
||
ps = sub.add_parser("show", help=COMMAND_HELP["show"])
|
||
ps.add_argument("media_id", type=int, help="收藏夹 media_id")
|
||
|
||
prp = sub.add_parser("replan", help=COMMAND_HELP["replan"])
|
||
prp.add_argument("media_id", type=int, help="收藏夹 media_id")
|
||
|
||
pr = sub.add_parser("run", help=COMMAND_HELP["run"])
|
||
pr.add_argument("media_id", type=int, help="收藏夹 media_id")
|
||
pr.add_argument("--verify-every", type=int, default=1, metavar="N",
|
||
help="每 N 步回读校验一次(默认 1;前 3 步始终逐步校验)")
|
||
|
||
pv = sub.add_parser("verify", help=COMMAND_HELP["verify"])
|
||
pv.add_argument("media_id", type=int, help="收藏夹 media_id")
|
||
|
||
ph = sub.add_parser("help", help=COMMAND_HELP["help"])
|
||
ph.add_argument("topic", nargs="?", default=None, metavar="<command>",
|
||
help="查看某个子命令的详细帮助,例如: help run")
|
||
return p, sub
|
||
|
||
|
||
def print_help(parser, sub, topic=None):
|
||
"""打印全部子命令与选项;指定 topic 时只打印该子命令的详细帮助。"""
|
||
if topic:
|
||
sp = sub.choices.get(topic)
|
||
if sp is None:
|
||
print(f"未知子命令: {topic}\n")
|
||
print(parser.format_help())
|
||
return 1
|
||
print(sp.format_help())
|
||
return 0
|
||
|
||
print(parser.format_help())
|
||
print("子命令与可用选项:")
|
||
for name, sp in sub.choices.items():
|
||
head = sp.format_usage().strip().removeprefix("usage: ")
|
||
print(f"\n {head}")
|
||
desc = COMMAND_HELP.get(name, "")
|
||
if desc:
|
||
print(f" {desc}")
|
||
for a in sp._actions: # noqa: SLF001 argparse 没有公开接口
|
||
if a.dest == "help":
|
||
continue
|
||
if a.option_strings:
|
||
flags = ", ".join(a.option_strings)
|
||
extra = "" if a.nargs == 0 else f" {a.metavar or a.dest.upper()}"
|
||
print(f" {flags + extra:<26} {a.help or ''}")
|
||
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 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。")
|
||
return 0
|
||
|
||
|
||
def main():
|
||
p, sub = build_parser()
|
||
args = p.parse_args()
|
||
|
||
if args.cmd in (None, "help"):
|
||
return print_help(p, sub, getattr(args, "topic", None))
|
||
|
||
cfg = load_config(require_auth=args.cmd != "auth")
|
||
ensure_data_dir()
|
||
|
||
handler = {
|
||
"auth": cmd_auth,
|
||
"folders": cmd_folders,
|
||
"plan": cmd_plan,
|
||
"show": cmd_show,
|
||
"run": cmd_run,
|
||
"verify": cmd_verify,
|
||
"replan": cmd_replan,
|
||
}[args.cmd]
|
||
return handler(cfg, args) or 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|