Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions extension/agent-writes.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,13 +325,14 @@
return bvids.map((b) => by.get(b));
}

// One run of B站 requests over items, a chunk per request, until the call's time or B站's limit ends it: send(chunk)
// One run of B站 requests over items, a chunk of size per request (20 for a folder's batch endpoints, TRIAGE_DEAL_MAX
// where B站 adds to a folder or takes out of 稍后再看, 1 for 稍后再看's add), until the call's time or B站's limit ends
// it: send(chunk)
// makes the request (triage-bg.js's handler, as the 分拣台 sends it), then landed(chunk, reply) keeps the local
// records. Resolves { done: the items written, failed: those B站 said no to (skip, one at a time), stopped?:
// "TIMEOUT" | "THROTTLED" }; another error is thrown with .done, the items written before it.
async function run(items, one, deadline, send, landed, { skip = false } = {}) {
const size = one ? 1 : 20;
const gap = one ? 300 : 1000;
async function run(items, size, deadline, send, landed, { skip = false } = {}) {
const gap = size === 1 ? 300 : 1000; // triage-bg.js spaces its batch adds itself (triageDealPost)
const done = [];
const failed = [];
for (let i = 0; i < items.length; i += size) {
Expand All @@ -345,7 +346,7 @@
if (e.code === MdgErrors.CODE.THROTTLED) return { done, failed, stopped: "THROTTLED" };
// B站 said no to this one video (deleted, say): an undo goes on with the others. A request that failed (HTTP
// 500, no JSON) is not that, and stops the run as any error.
if (skip && one && e.refused) {
if (skip && size === 1 && e.refused) {
failed.push(...chunk);
continue;
}
Expand All @@ -363,7 +364,7 @@
// 移动 / 复制 from → to, records kept as the 分拣台's transfer keeps them. Resolves run's result.
async function transfer(from, to, items, move, deadline) {
const [fromName, toName] = [await titleOf(from), await titleOf(to)];
return run(items, from === TOVIEW, deadline,
return run(items, from === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline,
(chunk) => TRIAGE_HANDLERS["triage-transfer"]({ from, to, aids: chunk.map(res), move }),
(chunk, reply) => R.lock(async () => {
const chosen = await chosenIds();
Expand All @@ -383,7 +384,7 @@

// Out of a folder with no 取消收藏 record (a 复制 undone): the cached list just loses them.
const remove = (folder, items, deadline) =>
run(items, folder === TOVIEW, deadline,
run(items, folder === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline,
(chunk) => TRIAGE_HANDLERS["triage-unfav"]({ mediaId: folder, aids: chunk.map(res) }),
(chunk) => R.lock(() => R.patchSnapshotNow(folder, { drop: chunk.map((it) => it.bvid) })));

Expand All @@ -394,14 +395,15 @@
const others = chosen.filter((id) => id !== folder && id !== TOVIEW);
const got = await chrome.storage.local.get(others.map(K.triageSnapshot));
const elsewhere = new Set(others.flatMap((id) => got[K.triageSnapshot(id)]?.bvids || []));
return run(items, folder === TOVIEW, deadline,
return run(items, folder === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline,
(chunk) => TRIAGE_HANDLERS["triage-unfav"]({ mediaId: folder, aids: chunk.map(res) }),
(chunk) => chosen.includes(folder) && R.lock(() => R.patchDecisionsNow(folder, Object.fromEntries(chunk.map((it) => [it.bvid, R.unfavRecord(it, Date.now(), batch, elsewhere.has(it.bvid))])))));
}

// Back into the folder, one per request (B站 has no batch for it); its 取消收藏 record goes.
// Back into the folder, one per request, so a video B站 refuses (deleted, say) is skipped and the others go on; its
// 取消收藏 record goes.
const refav = (folder, items, deadline) =>
run(items, true, deadline,
run(items, 1, deadline,
([it]) => TRIAGE_HANDLERS["triage-refav"]({ mediaId: folder, aid: res(it) }),
([it]) => R.lock(async () => {
await R.patchDecisionsNow(folder, { [it.bvid]: null });
Expand Down Expand Up @@ -567,7 +569,7 @@
const old = op === "move" ? items.filter((it) => had.has(it.bvid)) : [];
// 稍后再看 takes no 移动: each goes back into it, then out of the target.
if (op === "move" && from === TOVIEW) {
const r = await run(items, true, deadline,
const r = await run(items, 1, deadline,
async ([it]) => {
await TRIAGE_HANDLERS["triage-refav"]({ mediaId: TOVIEW, aid: res(it) });
if (!had.has(it.bvid)) await TRIAGE_HANDLERS["triage-unfav"]({ mediaId: to, aids: [res(it)] });
Expand Down
2 changes: 1 addition & 1 deletion extension/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"triage-zones": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["items"], response: data },
"triage-folder-create": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["title", "privacy"], response: data },
"triage-unfav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aids"], response: data },
"triage-refav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aid"], response: data },
"triage-refav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aids"], response: data },
"triage-transfer": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["from", "to", "aids", "move"], response: data },
"triage-title-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data },
"triage-analysis-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data },
Expand Down
15 changes: 9 additions & 6 deletions extension/triage/dev/mock-chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,15 @@
}
return { ok: true, data: { done: aids.length } };
},
"triage-refav": ({ mediaId, aid }) => {
const r = removed.get(`${mediaId}:${aid}`);
if (!r) return { ok: false, error: "找不到要恢复的视频" };
r.folder.items.splice(Math.min(r.index, r.folder.items.length), 0, r.item);
removed.delete(`${mediaId}:${aid}`);
return { ok: true, data: { done: 1 } };
"triage-refav": ({ mediaId, aid, aids }) => {
const list = aids || [aid];
const found = list.map((a) => removed.get(`${mediaId}:${a}`));
if (found.some((r) => !r)) return { ok: false, error: "找不到要恢复的视频" };
for (const [k, r] of found.entries()) {
r.folder.items.splice(Math.min(r.index, r.folder.items.length), 0, r.item);
removed.delete(`${mediaId}:${list[k]}`);
}
return { ok: true, data: { done: list.length } };
},
"triage-settings-get": () => ({
ok: true,
Expand Down
47 changes: 27 additions & 20 deletions extension/triage/fav-writes.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,22 @@ const W = MdgWrites.create({
line: favLine,
settleMs: globalThis.__TRIAGE_SETTLE_MS // the selftest's; undefined leaves writes.js's 3 s
});
// Bilibili takes 稍后再看 (and 收藏到 from 已出分拣范围) one video per request: one per chunk keeps the count exact on a
// failure. Other folders take 20, a second apart.
const chunkSize = (one) => (one ? 1 : 20);
const chunkGap = (one) => (one ? 300 : 1000);
function chunksOf(items, one, make) {
// Chunks a second apart. Bilibili takes 10 videos a request where it adds them to a folder (收藏 / 恢复收藏, and 移动 /
// 复制 out of 稍后再看: resource/batch-deal) or takes them out of 稍后再看, and the worker spaces those requests 3 s
// apart (triage-bg.js TRIAGE_DEAL_MAX, triageDealPost); its other batch endpoints take 20. Adding to 稍后再看 is one video
// a request. Each chunk is one request (or one add and one removal), all or nothing, so the count stays exact on a
// failure.
const DEAL_MAX = 10;
function chunksOf(items, size, make) {
const out = [];
for (let i = 0; i < items.length; i += chunkSize(one)) out.push({ ...make(items.slice(i, i + chunkSize(one))), gap: chunkGap(one) });
for (let i = 0; i < items.length; i += size) out.push({ ...make(items.slice(i, i + size)), gap: 1000 });
return out;
}
const refavChunk = (f, it) => ({ msg: { type: "triage-refav", mediaId: f, aid: favRes(it) }, keys: [it.bvid], folders: [f], item: favRef(it), gap: chunkGap(true) });
async function refavDone({ folders: [f], keys: [bvid] }) {
bumpCount(f, 1);
await patchDecisions(f, { [bvid]: null });
const refavChunks = (f, items) =>
chunksOf(items, f === TOVIEW ? 1 : DEAL_MAX, (its) => ({ msg: { type: "triage-refav", mediaId: f, aids: its.map(favRes) }, keys: its.map((it) => it.bvid), folders: [f], items: its.map(favRef) }));
async function refavDone({ folders: [f], keys }) {
bumpCount(f, keys.length);
await patchDecisions(f, Object.fromEntries(keys.map((b) => [b, null])));
renderStatus();
}
// A B站 batch write that failed midway: what went through stays.
Expand All @@ -148,33 +151,37 @@ const busyError = (r) => r.error || UI.busyText(r.busy);
// Re-favorites items ([{ bvid, aid, ogv }]) into mediaId, one per request; the first failure stops it. It changes Bilibili,
// so it runs to the end even after another folder opens. Returns { n, rest, error }.
async function refavInto(mediaId, items) {
const r = await W.run("refav", REFAV, { verb: REFAV, chunks: items.map((it) => refavChunk(mediaId, it)), folders: [mediaId], meta: { mediaId, verb: REFAV }, onChunk: refavDone });
const r = await W.run("refav", REFAV, { verb: REFAV, chunks: refavChunks(mediaId, items), folders: [mediaId], meta: { mediaId, verb: REFAV }, onChunk: refavDone });
render();
const went = new Set(r.keys || []);
return { n: r.done, rest: items.filter((it) => !went.has(it.bvid)), error: busyError(r), stopped: r.stopped };
}

// 所有收藏夹's batch 取消收藏 undone: each video back in every folder it was taken out of, as one run.
async function refavGone(gone) {
const chunks = Object.entries(gone).flatMap(([f, items]) => items.map((it) => refavChunk(f, it)));
const chunks = Object.entries(gone).flatMap(([f, items]) => refavChunks(f, items));
const went = new Set(); // the chunks that went through: a video taken out of two folders has a chunk in each
const r = await W.run("refav", REFAV, {
verb: REFAV,
chunks,
folders: [String(S.mediaId)],
meta: { mediaId: String(S.mediaId), verb: REFAV },
async onChunk(c) {
went.add(c);
await refavDone(c);
const [f] = c.folders;
const it = S.itemMap.get(c.keys[0]);
if (!it?.folders) return;
it.folders = [...new Set([...it.folders, f])];
it.left = (it.left || []).filter((x) => x !== f);
if (S.decisions[it.bvid]?.action === "unfav") unfavGone(it.bvid);
for (const bvid of c.keys) {
const it = S.itemMap.get(bvid);
if (!it?.folders) continue;
it.folders = [...new Set([...it.folders, f])];
it.left = (it.left || []).filter((x) => x !== f);
if (S.decisions[it.bvid]?.action === "unfav") unfavGone(it.bvid);
}
}
});
render();
const rest = {};
for (const c of chunks.slice(r.done)) (rest[c.folders[0]] ||= []).push(c.item);
for (const c of chunks) if (!went.has(c)) (rest[c.folders[0]] ||= []).push(...c.items);
return { n: r.done, rest, error: busyError(r), stopped: r.stopped };
}

Expand Down Expand Up @@ -305,7 +312,7 @@ const fav = {
const gone = {}; // folder → the videos taken out of it
const elsewhere = all ? null : await favedElsewhere(mediaId);
const chunks = folders.flatMap((f) =>
chunksOf(all ? list.filter((it) => it.folders.includes(f)) : list, f === TOVIEW, (items) => ({ msg: { type: "triage-unfav", mediaId: f, aids: items.map(favRes) }, keys: items.map((it) => it.bvid), folders: [f], items }))
chunksOf(all ? list.filter((it) => it.folders.includes(f)) : list, f === TOVIEW ? DEAL_MAX : 20, (items) => ({ msg: { type: "triage-unfav", mediaId: f, aids: items.map(favRes) }, keys: items.map((it) => it.bvid), folders: [f], items }))
);
const r = await W.run("unfav", UNFAV_MANY, {
verb: UNFAV,
Expand Down Expand Up @@ -385,7 +392,7 @@ const fav = {
S.included = await MdgLimits.update(K.included, (stored) => [...new Set([...(stored || []).map(String), meta.to])]);
}
const { to, toName } = meta;
const chunks = chunksOf(list, add || from === TOVIEW, (items) => ({ msg: { type: "triage-transfer", from: add ? "" : from, to, aids: items.map(favRes), move }, keys: items.map((it) => it.bvid), folders: [from, to], items }));
const chunks = chunksOf(list, add || from === TOVIEW ? DEAL_MAX : 20, (items) => ({ msg: { type: "triage-transfer", from: add ? "" : from, to, aids: items.map(favRes), move }, keys: items.map((it) => it.bvid), folders: [from, to], items }));
return {
verb,
chunks,
Expand Down
73 changes: 56 additions & 17 deletions extension/triage/triage-bg.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,9 @@ const triageNotLoggedIn = () => triageError(MdgErrors.TEXT.biliLogin, "NOT_LOGGE
function triageCodeError(json, what = "B站返回 ", keep = false) {
if (json.code === -101) return triageNotLoggedIn();
if (BILI_RISK_CODES.has(json.code)) return triageError(MdgErrors.TEXT.biliThrottled, MdgErrors.CODE.THROTTLED);
// refused: B站 answered and said no to this request (a deleted video, a full list), unlike a request that failed.
return Object.assign(triageError(`${what}${json.code}:${json.message || ""}`, keep ? json.code : undefined), { refused: true });
// refused: B站 answered and said no to this request (a deleted video, a full list), unlike a request that failed;
// biliCode is B站's code either way, for a caller that tells one refusal from another.
return Object.assign(triageError(`${what}${json.code}:${json.message || ""}`, keep ? json.code : undefined), { refused: true, biliCode: json.code });
}
function triageBiliData(json) {
if (json.code !== 0) throw triageCodeError(json);
Expand Down Expand Up @@ -946,6 +947,43 @@ const triageRes = (v) => {
return { rid, type: Number(type), resource: `${rid}:${type}` };
};
const triageAids = (v) => (Array.isArray(v) && v.every(triageResOk) ? v : []);
// Bilibili adds at most this many videos to a folder in one request (resource/batch-deal: 11 add the first 10 and fail
// with -702, 2026-11-12), and 稍后再看's removal takes as many (toview/del with aids joined by commas). Two batch adds
// right after each other are refused with the same -702 and add nothing; 3 s apart both go through. So every batch write
// here, whoever sends it (a page's run, another run started meanwhile, the MCP), waits until TRIAGE_DEAL_GAP_MS after
// the last one (triageDealPost). A -702 that still comes is asked again once after the gap; a second one is reported as
// B站 said it. Adding a video already there, or with an aid B站 does not know among the others, still answers 0
// (2026-11-12), so asking again is safe.
const TRIAGE_DEAL_MAX = 10;
const TRIAGE_DEAL_GAP_MS = 3000;
const TRIAGE_DEAL_BUSY = -702;
const triageDealParts = (list) => Array.from({ length: Math.ceil(list.length / TRIAGE_DEAL_MAX) }, (_, i) => list.slice(i * TRIAGE_DEAL_MAX, (i + 1) * TRIAGE_DEAL_MAX));
let triageDealAt = 0; // when the last batch write went out; the worker is the only sender
async function triageDealPost(path, fields) {
for (let again = true; ; again = false) {
// ponytail: a worker-wide stamp; two writes waiting at once both leave after the same stamp, then the next waits
const wait = triageDealAt + TRIAGE_DEAL_GAP_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
triageDealAt = Date.now();
try {
return await triageBiliPost(path, fields);
} catch (e) {
if (e.biliCode !== TRIAGE_DEAL_BUSY || !again) throw e;
} finally {
triageDealAt = Date.now();
}
}
}
// One video goes through resource/deal, as before batching: its refusal (稿件不存在…) is that video's, which the MCP's
// undo, adding one at a time, goes on past.
const triageDeal = (aids, mediaId) => {
if (aids.length === 1) {
const { rid, type } = triageRes(aids[0]);
return triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: mediaId, del_media_ids: "" });
}
return triageDealPost("/x/v3/fav/resource/batch-deal", { resources: aids.map((a) => triageRes(a).resource).join(","), add_media_ids: mediaId, del_media_ids: "", platform: "web" });
};
const triageToviewDel = (aids) => triageDealPost("/x/v2/history/toview/del", { aid: aids.map((a) => triageRes(a).rid).join(",") });
// The inverse of sites.js biliAvToBv; "" for anything that is not a BV id. A type-24 favorite's id is its episode id,
// so its archive aid comes from the list's (correct) bvid.
function triageBvToAv(bvid) {
Expand Down Expand Up @@ -1110,7 +1148,7 @@ const TRIAGE_HANDLERS = {
const list = triageAids(aids);
if (!triageFolderId(mediaId) || !list.length) throw triageError("缺少 mediaId 或 aids");
if (mediaId === TRIAGE_TOVIEW) {
for (const a of list) await triageBiliPost("/x/v2/history/toview/del", { aid: triageRes(a).rid });
for (const part of triageDealParts(list)) await triageToviewDel(part);
return { done: list.length };
}
await triageBiliPost("/x/v3/fav/resource/batch-del", {
Expand All @@ -1121,35 +1159,36 @@ const TRIAGE_HANDLERS = {
return { done: list.length };
},

"triage-refav": async ({ mediaId, aid }) => {
if (!triageFolderId(mediaId) || !triageResOk(aid)) throw triageError("缺少 mediaId 或 aid");
// aids (or one aid) back into mediaId: a folder takes TRIAGE_DEAL_MAX a request, 稍后再看 one.
"triage-refav": async ({ mediaId, aid, aids }) => {
const list = triageAids(aids ?? [aid]);
if (!triageFolderId(mediaId) || !list.length) throw triageError("缺少 mediaId 或 aid");
if (mediaId === TRIAGE_TOVIEW) {
await triageBiliPost("/x/v2/history/toview/add", { aid: triageRes(aid).rid });
for (const a of list) await triageBiliPost("/x/v2/history/toview/add", { aid: triageRes(a).rid });
return {};
}
const { rid, type } = triageRes(aid);
await triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: mediaId, del_media_ids: "" });
for (const part of triageDealParts(list)) await triageDeal(part, mediaId);
return {};
},

// 移动 / 复制 to another folder (never 稍后再看); without from, just 收藏 into it (videos from 已出分拣范围).
// From 稍后再看 or nowhere there is no batch endpoint: add one by one, then remove. The page sends one video per
// request from 稍后再看. A removal that B站 limits is thrown as it is: the run waits and sends the same video again (the
// add is idempotent). Any other failed removal is not undone and not a failure: the video is in the target and still
// in 稍后再看, answered as stayed (its aids) with the reason, so the page shows it copied rather than moved.
// From 稍后再看 or nowhere there is no move endpoint: add (TRIAGE_DEAL_MAX a request), then take them out of 稍后再看
// in one request; the page sends that many per chunk. A removal that B站 limits is thrown as it is: the run waits and
// sends the same chunk again (the add is idempotent). Any other failed removal is not undone and not a failure: the
// videos are in the target and still in 稍后再看, answered as stayed (their aids) with the reason, so the page shows
// them copied rather than moved.
"triage-transfer": async ({ from, to, aids, move }) => {
const list = triageAids(aids);
if (!triageNumId(to) || (from && !triageFolderId(from)) || String(from) === String(to) || !list.length) throw triageError("缺少目标或 aids");
if (!from || from === TRIAGE_TOVIEW) {
const stayed = [];
let stayedError = "";
for (const aid of list) {
const { rid, type } = triageRes(aid);
await triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: to, del_media_ids: "" });
for (const part of triageDealParts(list)) {
await triageDeal(part, to);
if (move && from) {
await triageBiliPost("/x/v2/history/toview/del", { aid: rid }).catch((e) => {
await triageToviewDel(part).catch((e) => {
if (e.code === MdgErrors.CODE.THROTTLED) throw e;
stayed.push(aid);
stayed.push(...part);
stayedError = e.message;
});
}
Expand Down
Loading
Loading