Files
re-xxm/favsort.py
T
kairee e2cdf997af 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.
2026-10-31 18:08:33 +08:00

945 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())