feat(p0): add favorite folder sync and sqlite data foundation

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.
This commit is contained in:
kairee committed 2026-10-31 18:08:33 +08:00
commit e2cdf997af
39 files changed
+8575

No files matched your search

+944
View File
@@ -0,0 +1,944 @@
# -*- 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())