Files
re-xxm/favsort.py
bot-ci 8e7ead1669 feat: switch config to JSONC + split secrets.json
Load business config as JSONC (comments + trailing commas) via an
in-repo stripper then stdlib json — no new deps. Default path prefers
config.jsonc, falls back to legacy config.json. Auth write-back targets
only sibling secrets.json so JSONC comments are never wiped; secrets
overlay merges on load. Examples, compose, Docker, docs, favsort and
tools all share the same loader. Phase A only — no DB settings migration.
2026-11-02 12:58:20 +08:00

716 lines
25 KiB
Python
Raw Permalink 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) 配置里的 overrides 可把指定视频强制归入另一个 upper(代发等场景)
指令
python favsort.py help [子命令]
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>
本工具只处理写账号所属收藏夹的排序。扫码授权统一使用
``python app.py auth --account write``;所有 API 请求均使用 ``bilibili.write``。
"""
import argparse
import json
import os
import random
import sys
import time
import urllib.parse
import httpx
sys.stdout.reconfigure(encoding="utf-8")
BASE = "https://api.bilibili.com"
ROOT = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(ROOT, "data")
from reaction_board.bilibili.auth import ( # noqa: E402
make_headers,
)
from reaction_board.config import ( # noqa: E402
ConfigError,
default_config_path,
load_config as load_app_config,
)
CONFIG_PATH = str(default_config_path(ROOT))
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",
}
# ---------------------------------------------------------------- config
DEFAULT_CONFIG = {
"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.jsonc 为 config.jsonc,并复制 secrets.example.json 为 secrets.json 后填入凭据\n"
f"(或运行 python app.py auth --account write)。"
)
try:
app_cfg = load_app_config(CONFIG_PATH, require_auth=False)
except ConfigError as exc:
raise SystemExit(str(exc)) from exc
write = app_cfg.bilibili.write
raw = app_cfg.raw
cfg = {
"mid": write.mid,
"csrf": write.csrf,
"cookie": write.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(
"bilibili.write.cookie / csrf 不能为空"
f"(写入 {app_cfg.secrets_path.name},或运行 python app.py auth --account write)"
)
return cfg
# ---------------------------------------------------------------- http
_HTTP_CLIENT: httpx.Client | None = None
def _http_client() -> httpx.Client:
global _HTTP_CLIENT
if _HTTP_CLIENT is None:
_HTTP_CLIENT = httpx.Client(
timeout=httpx.Timeout(30, connect=10),
limits=httpx.Limits(
max_keepalive_connections=5,
keepalive_expiry=30,
),
follow_redirects=False,
trust_env=True,
)
return _HTTP_CLIENT
def _get_json(cfg, url, auth, referer=None):
response = _http_client().get(
url,
headers=make_headers(cfg, auth=auth, referer=referer),
timeout=30,
)
response.raise_for_status()
return response.json()
def get_json(cfg, url, auth=False, referer=None):
"""只读请求:默认用去掉账号身份的 cookie。
万一匿名请求被拒(HTTP 错误,或 code != 0),自动带完整 cookie 重试一次,
保证功能不会因为剥离身份而回退。
"""
if auth:
return _get_json(cfg, url, auth=True, referer=referer)
try:
r = _get_json(cfg, url, auth=False, referer=referer)
if r.get("code") == 0:
return r
except Exception: # noqa: BLE001 网络错误 / 412 风控都走回退
pass
return _get_json(cfg, url, auth=True, referer=referer)
def post_form(cfg, url, data, referer=None):
headers = make_headers(cfg, auth=True, referer=referer)
headers["content-type"] = "application/x-www-form-urlencoded"
response = _http_client().post(
url, headers=headers, data=data, timeout=30
)
response.raise_for_status()
return response.json()
def fetch_ids(cfg, media_id):
referer = (
f"https://space.bilibili.com/{cfg['mid']}/favlist"
f"?fid={media_id}&ftype=create"
)
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id), referer=referer)
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
referer = (
f"https://space.bilibili.com/{folder_mid}/favlist"
f"?fid={media_id}&ftype=create"
)
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, referer=referer)
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 cmd_folders(cfg, args):
# 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3),
# 因此这里必须带完整 cookie(仍然只是 GET,不会改动任何数据)。
r = get_json(
cfg,
API["folders"].format(base=BASE, mid=cfg["mid"]),
auth=True,
referer=f"https://space.bilibili.com/{cfg['mid']}/favlist",
)
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,
"types": {v["avid"]: v["type"] for v in applied},
"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"]
types = plan.get("types") or {}
sc = cfg["sleep"]
verify_every = args.verify_every
if verify_every < 1:
print("[run] --verify-every 必须 >= 1;未执行任何写操作")
return 2
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"])
sort_referer = (
f"https://space.bilibili.com/{cfg['mid']}/favlist"
f"?fid={media_id}&ftype=create"
)
for i, m in enumerate(moves, 1):
pred_type = types.get(m["pred"], 2)
moved_type = types.get(m["moved"], 2)
body = {
"media_id": media_id,
"sort": f"{m['pred']}:{pred_type}:{m['moved']}:{moved_type}",
"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, referer=sort_referer
)
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, referer=sort_referer
)
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 = {
"folders": "列出写账号(bilibili.write.mid)的自建收藏夹及其 media_id",
"plan": "拉取收藏夹数据并生成排序计划(只读,不改动收藏夹)",
"show": "查看已生成的计划与完整移动脚本(离线)",
"replan": "离线重算移动脚本(不联网,不改目标顺序)",
"run": "按计划逐步执行移动(写操作,每步一个 sort 请求)",
"verify": "对比当前实际顺序与计划目标",
"help": "列出所有子命令与可用选项",
}
def _positive_int(value):
try:
number = int(value)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError("必须是整数") from exc
if number < 1:
raise argparse.ArgumentTypeError("必须 >= 1")
return number
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"])
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(默认取 bilibili.write.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=_positive_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 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鉴权:使用 python app.py auth --account write;"
"本工具的所有请求使用 bilibili.write,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=True)
ensure_data_dir()
handler = {
"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())