From 9c3309ba353e20e469484986ffb333b147ffe3a9 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:02:17 +0800 Subject: [PATCH 01/37] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=86=99=E5=85=A5=E4=BB=8E=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E7=8E=B0=E5=80=BC=E8=AF=BB=E6=94=B9=E5=86=99?= =?UTF-8?q?=EF=BC=8C=E9=A1=B5=E9=9D=A2=E8=B7=9F=E4=B8=8A=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=86=99=E7=9A=84=E7=BB=86=E7=9C=8B=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收藏夹的标签后端(tag-store.js local)原来拿页面内存里的标签和视频→标签表当底稿,锁只排队不读存储:别的标签页或后台刚写的、页面还没收到 storage 事件的那次修改,会被页面下一次写入整份盖掉。改成在同一把锁(MdgLimits.keyLock)里先读存储再改,页面副本取写入后的值。清理已出分拣范围的标签也改走这条写入,不再从内存拼整份。 页面原来只在打开收藏夹时读细看结果,现在跟随 triage_analysis_* 的变化,别处写入或删除的结论立刻显示。 测试:tag-store.selftest 加「页面副本没见过的写入保留」「交错写入都落地」,把写入改回读内存时前一条失败。 --- extension/limits.js | 2 ++ extension/triage/fav-folders.js | 6 ++--- extension/triage/fav-page.js | 8 ++++++ extension/triage/tag-store.js | 24 +++++++----------- extension/triage/tag-store.selftest.js | 35 ++++++++++++++++++++++++-- extension/triage/triage.selftest.js | 7 +++++- 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/extension/limits.js b/extension/limits.js index 842de664..dbafd2d8 100644 --- a/extension/limits.js +++ b/extension/limits.js @@ -140,6 +140,7 @@ lockQueues.set(name, done.catch(() => {})); return done; } + // The lock of one key's read-modify-writes (update); 收藏夹's tag list and map share KEYS.triageTags's. const keyLock = (key) => `mdg-key:${key}`; // A chrome.storage.local key changed by read-modify-write under its lock: edit(the stored value) returns the value to // write, or undefined to write nothing. Resolves what edit returned. 关注's keys go through updateFollow, whose one @@ -378,6 +379,7 @@ storageKeys, migrateRenamedLocalStorage, locked, + keyLock, update, saveVideoNote, updateConversations, diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index 72a4569e..a0ddc66d 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -610,9 +610,9 @@ async function cleanRemoved(list) { notePending.delete(b); await MdgLimits.saveVideoNote(b, "", (notes) => storeSet(K.notes, notes)); } - const write = { [K.removed]: rec, ...tagsDb.without(bvids) }; - noteOwnWrites(write); - if (!(await store(write))) return; + if (!(await storeSet(K.removed, rec))) return; + const untag = (map) => Object.fromEntries(Object.entries(map).filter(([b]) => !set.has(b))); + if (!(await tagsDb.write((cur) => ({ ...cur, map: untag(cur.map) })))) return; // 保留 and 待播 are every tab's: each by read-modify-write, only these videos taken out. await patchKept(Object.fromEntries(bvids.map((b) => [b, null]))); await editBasket((b) => b.filter((x) => !set.has(x.bvid))); diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 304ba5c1..8cfcb97d 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -138,6 +138,14 @@ function followShared(changes) { if (name === "tags") for (const id of [...S.tagFilter]) if (!tagById(id)) tagGone(id); changed = true; } + // 细看 results the background wrote (a 细看 here or in another tab, an AI tool's verdict) or 清理 removed. + for (const [key, c] of Object.entries(changes)) { + if (!key.startsWith(KEYS.triageAnalysisPrefix)) continue; + const b = key.slice(KEYS.triageAnalysisPrefix.length); + if (c.newValue) S.analyses[b] = c.newValue; + else delete S.analyses[b]; + changed = true; + } // 取消收藏 records: the open folder's, as openFolder builds them; in 所有收藏夹 those of the loaded folders. for (const [key, c] of Object.entries(changes)) { const id = /^triage_decisions_(.+)$/.exec(key)?.[1]; diff --git a/extension/triage/tag-store.js b/extension/triage/tag-store.js index 21931e99..3fb0957b 100644 --- a/extension/triage/tag-store.js +++ b/extension/triage/tag-store.js @@ -37,27 +37,21 @@ // page's writer has said why). put(part) gets only the changed parts ({ tags?, map? }) after they are stored. const changedParts = (cur, next) => Object.fromEntries(["tags", "map"].filter((k) => next[k] !== cur[k]).map((k) => [k, next[k]])); - // 收藏夹: the page holds them (get()); save(patch) is its storage write (echo bookkeeping, failure toast) → stored? - // One write at a time across the pages that hold these keys (MdgLimits.locked). + // 收藏夹: the page holds a copy (get()); save(patch) is its storage write (echo bookkeeping, failure toast) → stored? + // A write starts from what storage holds, under the lock the other tabs and the background (agent-writes.js, an AI + // tool's tags) take for these keys: the page's copy may not have their last write yet. put gets both parts. function local({ keys, get, put, save }) { function write(fn) { - return MdgLimits.locked(`mdg-key:${keys.tags}`, async () => { - const cur = get(); + return MdgLimits.locked(MdgLimits.keyLock(keys.tags), async () => { + const got = await chrome.storage.local.get([keys.tags, keys.map]); + const cur = { tags: got[keys.tags] || [], map: got[keys.map] || {} }; const part = changedParts(cur, fn(cur)); - if (!Object.keys(part).length) return true; - if (!(await save(Object.fromEntries(Object.entries(part).map(([k, v]) => [keys[k], v]))))) return false; - put(part); + if (Object.keys(part).length && !(await save(Object.fromEntries(Object.entries(part).map(([k, v]) => [keys[k], v]))))) return false; + put({ ...cur, ...part }); return true; }); } - // 清理 forgets videos in one write of several keys: the map without them, as a patch for that write (put now). - function without(itemKeys) { - const map = { ...get().map }; - for (const k of itemKeys) delete map[k]; - put({ map }); - return { [keys.map]: map }; - } - return { tags: () => get().tags, map: () => get().map, write, without }; + return { tags: () => get().tags, map: () => get().map, write }; } // 关注: follow_tags / follow_tag_map, under the lock the background takes for its own follow_* writes (a sync, B站 diff --git a/extension/triage/tag-store.selftest.js b/extension/triage/tag-store.selftest.js index d71e8c52..0ac66c1d 100644 --- a/extension/triage/tag-store.selftest.js +++ b/extension/triage/tag-store.selftest.js @@ -36,7 +36,16 @@ const { MdgTagStore: TS, TriageUi: UI } = ctx; // A page: the adapter with its words, data in memory behind the local backend (收藏夹's), or any backend given. function page(o = {}) { - const p = { tags: [], map: {}, said: [], asked: [], changed: 0, proposal: null, recent: null, saveOk: true, gone: [] }; + // The local backend writes from what storage holds: p.tags and p.map are the stored keys, each page starts empty. + delete store.tags; + delete store.map; + const p = { + get tags() { return store.tags || []; }, + set tags(v) { store.tags = v; }, + get map() { return store.map || {}; }, + set map(v) { store.map = v; }, + said: [], asked: [], changed: 0, proposal: null, recent: null, saveOk: true, gone: [] + }; const db = TS.local({ keys: { tags: "tags", map: "map" }, get: () => ({ tags: p.tags, map: p.map }), @@ -71,7 +80,7 @@ const proposal = (newTags, rows) => ({ newTags: newTags.map((name) => ({ key: na const p = page(); const a = await p.store.createTag(" 科普, "); assert.deepStrictEqual([a.name, a.id.startsWith("t"), p.tags.length, plain(store.tags).length], ["科普", true, 1, 1]); - assert.strictEqual(await p.store.createTag("科普"), a, "the same name is the same tag"); + assert.strictEqual((await p.store.createTag("科普")).id, a.id, "the same name is the same tag"); await p.store.createTag("二"); await p.store.createTag("三"); assert.strictEqual(await p.store.createTag("四"), null); @@ -215,6 +224,28 @@ const proposal = (newTags, rows) => ({ newTags: newTags.map((name) => ({ key: na assert.deepStrictEqual(plain([p.proposal, store.follow_tags.map((t) => t.name), store.follow_tags[1].id.startsWith("ft")]), [null, ["旧", "新"], true]); } + // 收藏夹's backend writes from storage under the key's lock, not from the page's copy: a write another context made + // (another tab, an AI tool through agent-writes.js) that the copy has not heard of yet stays, and overlapping writes + // all land. + { + delete store.tags; + delete store.map; + const mem = { tags: [], map: {} }; + const db = TS.local({ keys: { tags: "tags", map: "map" }, get: () => mem, put: (part) => Object.assign(mem, part), save: async (patch) => (await chrome.storage.local.set(patch), true) }); + const bg = load(["../common.js", "../errors.js", "../limits.js"]).MdgLimits; + const other = (key) => bg.locked(bg.keyLock("tags"), async () => { + const { map = {} } = await chrome.storage.local.get("map"); + await chrome.storage.local.set({ map: { ...map, [key]: ["t1"] } }); + }); + const mine = (key) => db.write((cur) => ({ ...cur, map: { ...cur.map, [key]: ["t1"] } })); + await other("z"); + assert.ok(await mine("a")); + assert.deepStrictEqual(plain(store.map), { z: ["t1"], a: ["t1"] }, "the write the page's copy had not seen stays"); + assert.deepStrictEqual(plain(mem.map), plain(store.map), "and the copy takes what is stored"); + await Promise.all([mine("b"), other("y"), mine("c")]); + assert.deepStrictEqual(Object.keys(store.map).sort(), ["a", "b", "c", "y", "z"], "overlapping writes all land"); + } + // 摘读记录's backend: the marks record, through the page's edit of records; a failed write is said by fail. { const KEY = ctx.MdgLimits.KEYS.readingMarks; diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 33718998..aaa28a0d 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -102,6 +102,11 @@ const flush = () => new Promise((r) => setImmediate(r)); const row3 = () => (t.renderListHeader(t.visibleItems()), t.el.classFilter.innerHTML); const realSync = t.syncFolder; const toasts = []; +// The tag store writes from what storage holds (tag-store.js local): here the page's copy of the tags and the map is the +// stored keys, so a test that sets S.tags or S.videoTags sets what is stored, as an earlier write would have. +for (const [name, key, empty] of [["tags", t.K.tags, []], ["videoTags", t.K.videoTags, {}]]) { + Object.defineProperty(t.S, name, { get: () => store[key] ?? empty, set: (v) => (store[key] = v), enumerable: true }); +} Object.assign(t, { render() {}, setFocus() {}, toast: (m) => toasts.push(m), askConfirm: async () => true }); // The picker as 收藏夹 opens it: po = the last open's options; pickWith ticks (fn gets the Map of Sets) and closes. @@ -1409,7 +1414,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(long.name, "入门进阶一二三四五六七八"); assert.strictEqual((await createTag(" 入门进阶一二三四五六七八 ")).id, long.id); assert.strictEqual((await createTag("、")), null, "a name that cleans to nothing is refused"); - t.S.tags = t.S.tags.filter((x) => x !== long); + t.S.tags = t.S.tags.filter((x) => x.id !== long.id); assert.ok(aiTags.roomHint().includes("AI 这次最多新建 4 个(这个收藏夹还剩 4 个名额)")); // A new tag takes the folder's first free color, so a deleted tag's color comes back instead of a taken one // repeating; 管理's color button moves to the next color. From 98bc4a63bce9f80b6894272c3cf1aaecbd47a3c5 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:05:10 +0800 Subject: [PATCH 02/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=E6=8C=89?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E9=80=82=E9=85=8D=E5=99=A8=E7=BB=84=E7=BB=87?= =?UTF-8?q?=EF=BC=9A=E6=B3=A8=E5=86=8C=E8=A1=A8=E5=8A=A0=20B=E7=AB=99?= =?UTF-8?q?=E3=80=81YouTube=20=E4=B8=A4=E4=B8=AA=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bridge 和 search 不再写死站点,改问 agent-sites/ 的注册表;新站点一个文件、一个自测、一行 importScripts。 适配器的 read 可以是函数:在桥自己的标签页的页面主世界里运行(chrome.scripting),没有授权时答 NO_ACCESS。 --- extension/agent-bridge.js | 71 ++++++++++------------ extension/agent-bridge.selftest.js | 52 ++++++++++++++-- extension/agent-search.js | 15 +++-- extension/agent-search.selftest.js | 11 +++- extension/agent-sites/bilibili.js | 19 ++++++ extension/agent-sites/bilibili.selftest.js | 21 +++++++ extension/agent-sites/registry.js | 31 ++++++++++ extension/agent-sites/registry.selftest.js | 33 ++++++++++ extension/agent-sites/youtube.js | 11 ++++ extension/agent-sites/youtube.selftest.js | 15 +++++ extension/background.js | 5 +- extension/conventions.selftest.js | 1 + scripts/selftest.mjs | 2 +- 13 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 extension/agent-sites/bilibili.js create mode 100644 extension/agent-sites/bilibili.selftest.js create mode 100644 extension/agent-sites/registry.js create mode 100644 extension/agent-sites/registry.selftest.js create mode 100644 extension/agent-sites/youtube.js create mode 100644 extension/agent-sites/youtube.selftest.js diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 0f04ddf4..fb3fa986 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -1,7 +1,8 @@ // MoonDigest's agent interface, docs/AGENT.md §2: the service worker's socket to the local MCP server (mcp/). // Call MdgAgentBridge.start(...) synchronously at the top level of the service worker so the // chrome.alarms listener is registered before Chrome delivers a wake-up alarm. -// Needs the "alarms", "storage" and "tabs" permissions (storage.session: Chrome 102+) plus host access to bilibili.com and youtube.com. +// Needs the "alarms", "storage", "tabs" and "scripting" permissions (storage.session: Chrome 102+) plus host access to +// each site's origins (agent-sites/): the manifest grants bilibili.com and youtube.com, the user allows the others. (() => { const SERVER_URL = "ws://127.0.0.1:47823/"; const ALARM = "agent-bridge"; @@ -11,32 +12,6 @@ const CALL_BUDGET_MS = 55_000; // a call ends before the server's 60 s TIMEOUT, so the server gets its error const PAGE_RETRY_MS = 500; - // --- URLs -------------------------------------------------------------------- - - // Returns "site:videoId:part" for a supported video page, else null. - function videoKey(href) { - let u; - try { u = new URL(href); } catch { return null; } - if (u.protocol !== "https:" && u.protocol !== "http:") return null; - const host = u.hostname.replace(/^www\./, ""); - const q = u.searchParams; - if (host === "youtube.com") { - const v = q.get("v"); - return u.pathname === "/watch" && v ? `youtube:${v}:` : null; - } - if (host === "bilibili.com") { - let m = /^\/video\/((?:BV|bv)[0-9A-Za-z]+|av\d+)\/?$/.exec(u.pathname); - if (m) return `bilibili:${m[1]}:${q.get("p") || "1"}`; - m = /^\/bangumi\/play\/((?:ep|ss)\d+)\/?$/.exec(u.pathname); - if (m) return `bilibili:${m[1]}:`; - if (/^\/(list|festival)\/[^/]+/.test(u.pathname)) { - // These pages play q.bvid; without it the page itself is the only identity. - return `bilibili:${q.get("bvid") || u.pathname}:${q.get("p") || "1"}`; - } - } - return null; - } - // --- tabs -------------------------------------------------------------------- // The one tab this bridge opened: { tabId, key, pageKey, staleUrl }. pageKey is the key the @@ -63,11 +38,11 @@ } // A tab other than ours already showing the video: use it as is, never navigate it. - async function findUserTab(key) { + async function findUserTab(site, key) { const own = await loadOwn(); - const tabs = await chrome.tabs.query({ url: ["*://*.bilibili.com/*", "*://*.youtube.com/*"] }); + const tabs = await chrome.tabs.query({ url: site.origins }); // A discarded tab has no content script until it is shown again: never pick one. - const hit = tabs.find((t) => (!own || t.id !== own.tabId) && !t.discarded && videoKey(t.url || "") === key); + const hit = tabs.find((t) => (!own || t.id !== own.tabId) && !t.discarded && MdgAgentSites.keyOf(t.url || "") === key); return hit ? { tabId: hit.id, staleUrl: null } : null; } @@ -76,7 +51,7 @@ let own = await loadOwn(); const tab = own && (await chrome.tabs.get(own.tabId).catch(() => null)); if (!tab) own = null; // never opened, or the user closed it - const k = tab ? videoKey(tab.url || "") : null; + const k = tab ? MdgAgentSites.keyOf(tab.url || "") : null; // Without a pageKey (no call answered there yet) the tab counts as this video's only while it is still loading it // (a redirect, such as /video/av… to /video/BV…): once loaded it may show anything the user went to. if (own && (k === key || (own.key === key && (own.pageKey ? k === own.pageKey : tab.status === "loading")))) return own; @@ -93,10 +68,10 @@ return own; } - // Retries until the tab's content script answers; get and frame are safe to repeat. + // Retries until the tab's content script (or ask) answers; get and frame are safe to repeat. // After a navigation it does not ask until the tab's URL has left the previous page, // so the previous video's content script never answers. - async function callPage(target, method, params, deadline) { + async function callPage(target, method, params, deadline, ask = (tabId) => chrome.tabs.sendMessage(tabId, { type: "agent-call", method, params })) { const until = Math.min(Date.now() + PAGE_WAIT_MS, deadline); for (;;) { let reply; @@ -108,7 +83,7 @@ else target.staleUrl = null; } if (ready) { - reply = await chrome.tabs.sendMessage(target.tabId, { type: "agent-call", method, params }).catch(() => undefined); + reply = await ask(target.tabId).catch(() => undefined); if (reply && typeof reply.ok === "boolean") return reply; } if (Date.now() > until) { @@ -136,6 +111,13 @@ } } + // A site whose adapter reads the page itself: read(url) runs in the page's own world (its globals in reach) and returns + // §1's get data, or a falsy value while the page is not ready (callPage asks again). + const readInPage = (site, url) => async (tabId) => { + const [r] = await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: site.read, args: [url] }); + return r?.result ? { ok: true, data: { ...r.result, site: site.id } } : undefined; + }; + // deadline: when the server stops waiting for this call (its 60 s TIMEOUT). A call still queued then is dropped, so // nothing loads a video or asks the site for a call nobody waits for any more. // Methods other files add (agent-mydata.js): each takes params and the deadline and resolves to a reply, without a tab. @@ -147,18 +129,27 @@ if (Object.hasOwn(added, method)) return added[method](params, deadline); if (method !== "get" && method !== "frame") return { ok: false, error: `Unknown method ${method}.` }; const url = params && params.url; - const key = typeof url === "string" ? videoKey(url) : null; - if (!key) return { ok: false, code: "BAD_URL", error: "Not a supported page: use a Bilibili video page or a YouTube watch page." }; + const key = typeof url === "string" ? MdgAgentSites.keyOf(url) : null; + if (!key) return { ok: false, code: "BAD_URL", error: `Not a page MoonDigest reads. Sites: ${MdgAgentSites.list().map((s) => s.id).join(", ")}.` }; + const site = MdgAgentSites.get(key.split(":")[0]); const { url: _drop, ...rest } = params; - const user = await findUserTab(key); - if (user) return callShown(user, method, rest, deadline); + if (site.read === "content") { + const user = await findUserTab(site, key); + if (user) return callShown(user, method, rest, deadline); + } else { + // Only in the own tab: nothing runs in the user's tabs. + if (method !== "get") return { ok: false, error: `${site.id} pages answer get only.` }; + if (!(await chrome.permissions.contains({ origins: site.origins }))) { + return { ok: false, code: "NO_ACCESS", error: `MoonDigest has no access to ${site.origins.join(", ")}: ask the user to allow it.` }; + } + } return inOwnTab(async () => { if (Date.now() > deadline) return { ok: false, code: "TIMEOUT", error: "The call waited too long behind earlier ones." }; const own = await ownTabFor(key, url); - const reply = await callShown(own, method, rest, deadline); + const reply = site.read === "content" ? await callShown(own, method, rest, deadline) : await callPage(own, method, rest, deadline, readInPage(site, url)); if (!own.pageKey && reply.ok) { const tab = await chrome.tabs.get(own.tabId).catch(() => null); - own.pageKey = tab ? videoKey(tab.url || "") : null; + own.pageKey = tab ? MdgAgentSites.keyOf(tab.url || "") : null; } await saveOwn(own); return reply; diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 1fc979fb..1c0ed53b 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -7,13 +7,15 @@ const path = process.getBuiltinModule("node:path"); const assert = process.getBuiltinModule("node:assert/strict"); const here = path.dirname(fs.realpathSync(process.argv[1])); -const SOURCE = fs.readFileSync(path.join(here, "agent-bridge.js"), "utf8"); +// The worker's order: the registry, the sites, the bridge. +const SOURCES = ["agent-sites/registry.js", "agent-sites/bilibili.js", "agent-sites/youtube.js", "agent-bridge.js"].map((f) => [f, fs.readFileSync(path.join(here, f), "utf8")]); const tick = () => new Promise((r) => setImmediate(r)); const settle = async () => { for (let i = 0; i < 20; i++) await tick(); }; -function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = false }) { - const env = { sockets: [], timers: [], storage: {}, alarms: new Set(), created: [], updated: [], shown: [], sent: [], enabledCb: null, alarmCb: null, pageReplies: [] }; +// granted: the origins chrome.permissions.contains answers true for; inject(tab, details): executeScript's result. +function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = false, granted = [], inject = null }) { + const env = { sockets: [], timers: [], storage: {}, alarms: new Set(), created: [], updated: [], shown: [], sent: [], injected: [], enabledCb: null, alarmCb: null, pageReplies: [] }; let nextTimer = 1; let nextTab = 100; class FakeWebSocket { @@ -26,6 +28,13 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = receive(msg) { this.onmessage({ data: JSON.stringify(msg) }); } } const chrome = { + permissions: { contains: async ({ origins }) => origins.every((o) => granted.includes(o)) }, + scripting: { + executeScript: async (d) => { + env.injected.push({ tabId: d.target.tabId, world: d.world, args: JSON.parse(JSON.stringify(d.args)) }); // out of the vm realm + return [{ result: await inject(tabs.find((t) => t.id === d.target.tabId), d) }]; + }, + }, alarms: { create: (name) => env.alarms.add(name), clear: (name) => env.alarms.delete(name), @@ -72,7 +81,8 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = }; ctx.globalThis = ctx; vm.createContext(ctx); - vm.runInContext(SOURCE, ctx, { filename: "agent-bridge.js" }); + for (const [filename, source] of SOURCES) vm.runInContext(source, ctx, { filename }); + env.sites = ctx.MdgAgentSites; env.runTimers = () => { const ts = env.timers; env.timers = []; for (const t of ts) t.fn(); }; env.start = () => ctx.MdgAgentBridge.start({ extensionVersion: "1.2.3", @@ -410,6 +420,40 @@ check("a call still queued when the server has given up is dropped", async () => assert.equal(env.updated.length, 0); }); +// A site whose adapter reads the page itself (read is a function run in the page's MAIN world). +const DEMO = "https://www.demo.test/v/1"; +const demoSite = (env) => env.sites.register({ + id: "demo", + origins: ["https://www.demo.test/*"], + key: (u) => (u.hostname === "www.demo.test" && u.pathname.startsWith("/v/") ? `${u.pathname.slice(3)}:` : null), + read: () => globalThis.__STATE__, +}); + +check("a page-read site without access: NO_ACCESS, no tab, nothing injected; frame refused", async () => { + const env = makeEnv({ enabled: true }); + demoSite(env); + const s = await openSocket(env); + assert.deepEqual(await rpc(s, "1", "get", { url: DEMO }), { id: "1", ok: false, error: "MoonDigest has no access to https://www.demo.test/*: ask the user to allow it.", code: "NO_ACCESS" }); + assert.deepEqual(await rpc(s, "2", "frame", { url: DEMO, at: 1 }), { id: "2", ok: false, error: "demo pages answer get only." }); + assert.match((await rpc(s, "3", "get", { url: "https://example.com/" })).error, /Sites: bilibili, youtube, demo\./); + assert.equal(env.created.length, 0); + assert.equal(env.injected.length, 0); +}); + +check("a page-read site runs read in its own tab's MAIN world, never the user's tab, until the page is ready", async () => { + const replies = [null, { title: "t", site: "spoofed" }]; + const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], tabs: [{ id: 7, url: DEMO }], inject: () => replies.shift() }); + demoSite(env); + const s = await openSocket(env); + const r = await call(env, s, "1", DEMO); + assert.deepEqual(r, { id: "1", ok: true, data: { title: "t", site: "demo" } }); + assert.equal(env.created.length, 1); + assert.equal(env.created[0].active, false); + assert.deepEqual(env.injected.map((i) => i.tabId), [env.created[0].id, env.created[0].id]); + assert.deepEqual(env.injected[0], { tabId: env.created[0].id, world: "MAIN", args: [DEMO] }); + assert.equal(env.sent.length, 0, "no content-script message"); +}); + (async () => { let failed = 0; for (const [name, fn] of cases) { diff --git a/extension/agent-search.js b/extension/agent-search.js index a4b539c8..e29041eb 100644 --- a/extension/agent-search.js +++ b/extension/agent-search.js @@ -1,5 +1,6 @@ -// MoonDigest's agent interface, docs/AGENT.md §2 method `search`: a video search on Bilibili or YouTube, run in the -// service worker with the user's cookies (no tab). agent-bridge.js dispatches to MdgAgentSearch.search. +// MoonDigest's agent interface, docs/AGENT.md §2 method `search`: a video search on a site whose adapter has search +// (agent-sites/), run in the service worker with the user's cookies (no tab). agent-bridge.js dispatches to +// MdgAgentSearch.search; the queue and the checks here are every site's. Bilibili's and YouTube's search are below. // Bilibili goes through triage-bg.js's channel (triageBiliGet, TRIAGE_BILI_IO for the WBI key), YouTube through // background.js's fetchWithTimeout; the worker loads those after this file, so they are read only when a search runs. (() => { @@ -98,7 +99,11 @@ const site = params?.site; const query = typeof params?.query === "string" ? params.query.trim() : ""; const page = params?.page ?? 1; - if (site !== "bilibili" && site !== "youtube") return Promise.resolve({ ok: false, error: 'site must be "bilibili" or "youtube".' }); + const adapter = typeof site === "string" ? MdgAgentSites.get(site) : null; + if (!adapter?.search) { + const ids = MdgAgentSites.list().filter((s) => s.search).map((s) => `"${s.id}"`); + return Promise.resolve({ ok: false, error: `site must be one of ${ids.join(", ")}.` }); + } if (!query) return Promise.resolve({ ok: false, error: "query is required." }); if (!Number.isInteger(page) || page < 1) return Promise.resolve({ ok: false, error: "page must be an integer ≥ 1." }); const run = queue.then(async () => { @@ -106,7 +111,7 @@ if (wait > 0) await new Promise((r) => setTimeout(r, wait)); if (Date.now() > deadline) return { ok: false, code: "TIMEOUT", error: "The search waited too long behind earlier ones." }; try { - const items = await (site === "bilibili" ? searchBili : searchYt)(query, page); + const items = await adapter.search(query, page); return { ok: true, data: { site, query, page, items } }; } catch (e) { return { ok: false, error: e.message, ...(e.code === MdgErrors.CODE.THROTTLED && { code: e.code }) }; @@ -118,5 +123,5 @@ return run; } - globalThis.MdgAgentSearch = { search, biliItems, ytItems }; + globalThis.MdgAgentSearch = { search, searchBili, searchYt, biliItems, ytItems }; })(); diff --git a/extension/agent-search.selftest.js b/extension/agent-search.selftest.js index 24fce2df..cb22cae5 100644 --- a/extension/agent-search.selftest.js +++ b/extension/agent-search.selftest.js @@ -13,7 +13,7 @@ const ctx = vm.createContext({ setTimeout: (fn, ms) => (waits.push(ms), setImmediate(fn)), chrome: { storage: { local: {} } } }); -for (const f of ["common.js", "limits.js", "errors.js", "sites.js", "agent-search.js"]) vm.runInContext(fs.readFileSync(path.join(__dirname, f), "utf8"), ctx); +for (const f of ["common.js", "limits.js", "errors.js", "sites.js", "agent-sites/registry.js", "agent-search.js", "agent-sites/bilibili.js", "agent-sites/youtube.js"]) vm.runInContext(fs.readFileSync(path.join(__dirname, f), "utf8"), ctx); const A = ctx.MdgAgentSearch; const eq = (actual, expected) => assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected); @@ -66,7 +66,14 @@ ctx.fetchWithTimeout = async (url, init) => (calls.push(url), { ok: ytStatus < 3 (async () => { const far = Date.now() + 60_000; - eq(await A.search({ site: "vimeo", query: "x" }, far), { ok: false, error: 'site must be "bilibili" or "youtube".' }); + eq(await A.search({ site: "vimeo", query: "x" }, far), { ok: false, error: 'site must be one of "bilibili", "youtube".' }); + // A site without search is not offered; one with search is dispatched to, through the same queue. + ctx.MdgAgentSites.register({ id: "nosearch", origins: ["https://n.test/*"], key: () => null, read: "content" }); + ctx.MdgAgentSites.register({ id: "demo", origins: ["https://d.test/*"], key: () => null, read: "content", search: async (q, p) => [{ q, p }] }); + eq(await A.search({ site: "nosearch", query: "x" }, far), { ok: false, error: 'site must be one of "bilibili", "youtube", "demo".' }); + eq(await A.search({ site: "demo", query: " x ", page: 3 }, far), { ok: true, data: { site: "demo", query: "x", page: 3, items: [{ q: "x", p: 3 }] } }); + eq(await A.search({ site: "toString", query: "x" }, far), { ok: false, error: 'site must be one of "bilibili", "youtube", "demo".' }); + waits.length = 0; eq(await A.search({ site: "bilibili", query: " " }, far), { ok: false, error: "query is required." }); eq(await A.search({ site: "bilibili", query: "x", page: 0 }, far), { ok: false, error: "page must be an integer ≥ 1." }); assert.strictEqual(calls.length, 0); diff --git a/extension/agent-sites/bilibili.js b/extension/agent-sites/bilibili.js new file mode 100644 index 00000000..67f1c213 --- /dev/null +++ b/extension/agent-sites/bilibili.js @@ -0,0 +1,19 @@ +// Bilibili for the agent interface (docs/AGENT.md §5). The content script (agent-page.js) reads its pages; its search +// stays in agent-search.js with YouTube's, which share the text helpers. +MdgAgentSites.register({ + id: "bilibili", + origins: ["*://*.bilibili.com/*"], + key(u) { + if (u.hostname.replace(/^www\./, "") !== "bilibili.com") return null; + const q = u.searchParams; + let m = /^\/video\/((?:BV|bv)[0-9A-Za-z]+|av\d+)\/?$/.exec(u.pathname); + if (m) return `${m[1]}:${q.get("p") || "1"}`; + m = /^\/bangumi\/play\/((?:ep|ss)\d+)\/?$/.exec(u.pathname); + if (m) return `${m[1]}:`; + // These pages play q.bvid; without it the page itself is the only identity. + if (/^\/(list|festival)\/[^/]+/.test(u.pathname)) return `${q.get("bvid") || u.pathname}:${q.get("p") || "1"}`; + return null; + }, + read: "content", + search: (query, page) => MdgAgentSearch.searchBili(query, page) +}); diff --git a/extension/agent-sites/bilibili.selftest.js b/extension/agent-sites/bilibili.selftest.js new file mode 100644 index 00000000..38d6dc64 --- /dev/null +++ b/extension/agent-sites/bilibili.selftest.js @@ -0,0 +1,21 @@ +// node extension/agent-sites/bilibili.selftest.js +const assert = require("assert"); +const S = require("./registry.selftest.js").load("bilibili.js", "youtube.js"); + +const keys = { + "https://www.bilibili.com/video/BV1xx411c7mD": "bilibili:BV1xx411c7mD:1", + "https://www.bilibili.com/video/BV1xx411c7mD/?p=2&spm_id_from=x": "bilibili:BV1xx411c7mD:2", + "https://bilibili.com/video/av170001": "bilibili:av170001:1", + "https://www.bilibili.com/bangumi/play/ep12345": "bilibili:ep12345:", + "https://www.bilibili.com/bangumi/play/ss678/": "bilibili:ss678:", + "https://www.bilibili.com/list/ml123?bvid=BV1yy&p=3": "bilibili:BV1yy:3", + "https://www.bilibili.com/list/watchlater": "bilibili:/list/watchlater:1", + "https://www.bilibili.com/festival/2024bnj?bvid=BV1zz": "bilibili:BV1zz:1", + "https://www.bilibili.com/": null, + "https://www.bilibili.com/video/": null, + "https://space.bilibili.com/123/video": null, + "https://www.bilibili.com.evil.test/video/BV1xx411c7mD": null +}; +for (const [href, key] of Object.entries(keys)) assert.strictEqual(S.keyOf(href), key, href); +assert.strictEqual(S.get("bilibili").read, "content"); +console.log("agent-sites bilibili selftest ok"); diff --git a/extension/agent-sites/registry.js b/extension/agent-sites/registry.js new file mode 100644 index 00000000..ee337cbd --- /dev/null +++ b/extension/agent-sites/registry.js @@ -0,0 +1,31 @@ +// The sites the agent interface reads (docs/AGENT.md §5): one adapter file each in agent-sites/, registered here. +// agent-bridge.js and agent-search.js ask the registry; they name no site. +(() => { + const sites = new Map(); + + function register(adapter) { + const { id, origins, key, read } = adapter || {}; + // The id prefixes every key, so a ":" in it would split keys wrongly. + const bad = !/^[a-z0-9-]+$/.test(id) || sites.has(id) || !Array.isArray(origins) || !origins.length || typeof key !== "function" || + (read !== "content" && typeof read !== "function"); + if (bad) throw new Error(`MdgAgentSites: bad adapter ${id}`); + sites.set(id, adapter); + } + + // "id:itemId:part" for a page some site reads, else null. The adapter's key(URL) returns "itemId:part". + function keyOf(href) { + let u; + try { u = new URL(href); } catch { return null; } + if (u.protocol !== "https:" && u.protocol !== "http:") return null; + for (const s of sites.values()) { + const k = s.key(u); + if (k) return `${s.id}:${k}`; + } + return null; + } + + const get = (id) => sites.get(id) || null; + const list = () => [...sites.values()]; + + globalThis.MdgAgentSites = { register, keyOf, get, list }; +})(); diff --git a/extension/agent-sites/registry.selftest.js b/extension/agent-sites/registry.selftest.js new file mode 100644 index 00000000..d8a779ed --- /dev/null +++ b/extension/agent-sites/registry.selftest.js @@ -0,0 +1,33 @@ +// node extension/agent-sites/registry.selftest.js +// load(...files) runs registry.js and the named site files in a fresh vm and returns its MdgAgentSites; each site's +// selftest uses it. Run directly, it checks the registry itself. +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +function load(...files) { + const ctx = vm.createContext({ URL }); + for (const f of ["registry.js", ...files]) vm.runInContext(fs.readFileSync(path.join(__dirname, f), "utf8"), ctx, { filename: f }); + return ctx.MdgAgentSites; +} +module.exports = { load }; + +if (require.main === module) { + const S = load(); + const site = { id: "demo", origins: ["https://demo.test/*"], key: (u) => (u.hostname === "demo.test" ? `${u.pathname.slice(1)}:` : null), read: "content" }; + S.register(site); + assert.strictEqual(S.keyOf("https://demo.test/abc"), "demo:abc:", "the registry prefixes the id"); + assert.strictEqual(S.keyOf("http://demo.test/abc"), "demo:abc:"); + for (const href of ["ftp://demo.test/abc", "javascript:alert(1)", "not a url", "https://other.test/abc"]) assert.strictEqual(S.keyOf(href), null, href); + assert.strictEqual(S.get("demo"), site); + assert.strictEqual(S.get("nope"), null); + assert.strictEqual(S.get("__proto__"), null); + assert.deepStrictEqual(Array.from(S.list(), (s) => s.id), ["demo"]); + // Refused: a duplicate, an id that would split keys, a missing part, a read that is neither "content" nor a function. + for (const bad of [site, { ...site, id: "a:b" }, { ...site, id: "x", origins: [] }, { ...site, id: "x", key: null }, { ...site, id: "x", read: "main" }, null]) { + assert.throws(() => S.register(bad), /MdgAgentSites/); + } + assert.strictEqual(S.list().length, 1); + console.log("agent-sites registry selftest ok"); +} diff --git a/extension/agent-sites/youtube.js b/extension/agent-sites/youtube.js new file mode 100644 index 00000000..63495c8f --- /dev/null +++ b/extension/agent-sites/youtube.js @@ -0,0 +1,11 @@ +// YouTube for the agent interface (docs/AGENT.md §5). The content script (agent-page.js) reads its watch pages. +MdgAgentSites.register({ + id: "youtube", + origins: ["*://*.youtube.com/*"], + key(u) { + const v = u.searchParams.get("v"); + return u.hostname.replace(/^www\./, "") === "youtube.com" && u.pathname === "/watch" && v ? `${v}:` : null; + }, + read: "content", + search: (query, page) => MdgAgentSearch.searchYt(query, page) +}); diff --git a/extension/agent-sites/youtube.selftest.js b/extension/agent-sites/youtube.selftest.js new file mode 100644 index 00000000..873125b6 --- /dev/null +++ b/extension/agent-sites/youtube.selftest.js @@ -0,0 +1,15 @@ +// node extension/agent-sites/youtube.selftest.js +const assert = require("assert"); +const S = require("./registry.selftest.js").load("bilibili.js", "youtube.js"); + +const keys = { + "https://www.youtube.com/watch?v=abc&t=30": "youtube:abc:", + "https://youtube.com/watch?v=abc": "youtube:abc:", + "https://www.youtube.com/": null, + "https://www.youtube.com/watch": null, + "https://www.youtube.com/shorts/abc": null, + "https://m.youtube.com/watch?v=abc": null +}; +for (const [href, key] of Object.entries(keys)) assert.strictEqual(S.keyOf(href), key, href); +assert.strictEqual(S.get("youtube").read, "content"); +console.log("agent-sites youtube selftest ok"); diff --git a/extension/background.js b/extension/background.js index 96810afb..c78f508f 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,6 +1,9 @@ // Shared modules must load before the top-level code below reads them (e.g. MdgLimits). sidepanel-state.js only for // SidepanelState.patchReply (a reply finished after the panel closed). -importScripts("common.js", "errors.js", "limits.js", "settings.js", "sites.js", "tag-core.js", "note.js", "triage/shared.js", "sidepanel-state.js", "agent-search.js", "agent-bridge.js"); +importScripts("common.js", "errors.js", "limits.js", "settings.js", "sites.js", "tag-core.js", "note.js", "triage/shared.js", "sidepanel-state.js", "agent-sites/registry.js", "agent-search.js", "agent-bridge.js"); +// The sites the agent interface reads (docs/AGENT.md §5), one line each. +importScripts("agent-sites/bilibili.js"); +importScripts("agent-sites/youtube.js"); // The agent interface's MCP socket (agent-bridge.js, docs/AGENT.md §2): the local moondigest-mcp server (mcp/) asks // for a video, the bridge opens or reuses its tab and asks agent-page.js there. Off until diff --git a/extension/conventions.selftest.js b/extension/conventions.selftest.js index f1c22541..a3b1a40d 100644 --- a/extension/conventions.selftest.js +++ b/extension/conventions.selftest.js @@ -66,6 +66,7 @@ const ALLOW = [ ["content.js", 'new Error("Unknown retry error")', "例外:到不了,retryAsync 的循环每条路都 return 或 throw"], ["obsidian.js", "new Error(`cover HTTP ${image.status}`)", "例外:同一函数 catch 住,只 console.warn,笔记保留远程封面地址"], ["sidepanel-state.js", "new Error(`unknown event ${event.type}`)", "例外:未知事件类型是代码错,不是用户能遇到的"], + ["agent-sites/registry.js", "new Error(`MdgAgentSites: bad adapter ${id}`)", "例外:站点文件写错是代码错,worker 加载时就失败,用户遇不到"], ["sites.js", 'new Error("no wbi_img")', "例外:biliWbiQuery 吞掉它,follow-bg.js 的 followWbiData 换成「拿不到 WBI 签名密钥」"], // 5a ["triage/triage-bg.js", "[triageNotes]: notes, triage_notes_migrated: true", "例外:一次性迁移(triage_notes_migrated),扩展更新时跑一次,只补还没有的条目"], diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 80ec8b99..230cedd1 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const ext = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "extension"); -const tests = ["", "triage", "history"].flatMap((dir) => +const tests = ["", "triage", "history", "agent-sites"].flatMap((dir) => fs.readdirSync(path.join(ext, dir)).filter((f) => f.endsWith(".selftest.js")).sort().map((f) => path.join(ext, dir, f)) ); for (const test of tests) { From cbb350f67f78064f2f078a1b105af5f1af1075bf Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:05:11 +0800 Subject: [PATCH 03/37] =?UTF-8?q?MCP=EF=BC=9Asearch=5Fvideos=20=E7=9A=84?= =?UTF-8?q?=20site=20=E4=BA=A4=E7=BB=99=E6=89=A9=E5=B1=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 哪些站点能搜由扩展的注册表决定,服务端只查是不是字符串;未知站点由扩展答出可用列表。 --- mcp/server.js | 7 ++++--- mcp/test/server.test.mjs | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/mcp/server.js b/mcp/server.js index 5f8371c2..3a453ead 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -164,7 +164,7 @@ export function formatVideo(d) { // One line per result: what an agent needs to pick which ones to read with get_video. export function formatSearch(d) { - const site = d.site === "youtube" ? "YouTube" : "Bilibili"; + const site = { bilibili: "Bilibili", youtube: "YouTube" }[d.site] || d.site; if (!d.items?.length) return `No ${site} videos for "${d.query}" on page ${d.page}.`; const lines = [`${site} search "${d.query}", page ${d.page}:`]; d.items.forEach((v, i) => { @@ -225,7 +225,7 @@ const TOOLS = [ type: "object", properties: { query: { type: "string", description: "What to search for, as you would type it into the site's search box." }, - site: { type: "string", enum: ["bilibili", "youtube"], description: "Default bilibili." }, + site: { type: "string", description: "The site to search: \"bilibili\" (default) or \"youtube\"." }, page: { type: "integer", minimum: 1, description: "Result page, default 1, about 20 videos per page. YouTube has page 1 only." }, raw: { type: "boolean", description: "Return the extension's JSON (adds a short description per video) instead of the compact text." }, }, @@ -325,7 +325,8 @@ export function createMcpServer(bridge) { if (params.name === "search_videos") { if (typeof args.query !== "string" || !args.query.trim()) throw new CodedError("", "query is required."); const site = args.site ?? "bilibili"; - if (site !== "bilibili" && site !== "youtube") throw new CodedError("", 'site must be "bilibili" or "youtube".'); + // Which sites search is the extension's to say (agent-sites/): it answers any other id with the list. + if (typeof site !== "string" || !site) throw new CodedError("", 'site must be a site id such as "bilibili".'); const page = args.page ?? 1; if (!Number.isInteger(page) || page < 1) throw new CodedError("", "page must be an integer ≥ 1."); const data = await bridge.call("search", { site, query: args.query.trim(), page }); diff --git a/mcp/test/server.test.mjs b/mcp/test/server.test.mjs index ccff8192..b99c5639 100644 --- a/mcp/test/server.test.mjs +++ b/mcp/test/server.test.mjs @@ -230,7 +230,7 @@ test("search_videos: bad arguments never reach the extension", async () => { const ext = fakeExtension(s.bridge.port, { onCall: () => ({ ok: true, data: RESULTS }) }); await ext.opened; await until(() => s.bridge.connected); - for (const [args, re] of [[{}, /query is required/], [{ query: "x", site: "vimeo" }, /site must be/], [{ query: "x", page: 0 }, /page must be/], [{ query: "x", page: 1.5 }, /page must be/]]) { + for (const [args, re] of [[{}, /query is required/], [{ query: "x", site: "" }, /site must be/], [{ query: "x", page: 0 }, /page must be/], [{ query: "x", page: 1.5 }, /page must be/]]) { const r = await s.client.callTool({ name: "search_videos", arguments: args }); assert.equal(r.isError, true); assert.match(r.content[0].text, re); @@ -240,6 +240,20 @@ test("search_videos: bad arguments never reach the extension", async () => { await s.close(); }); +test("search_videos: the extension says which sites search", async () => { + const s = await setup(); + const ext = fakeExtension(s.bridge.port, { onCall: () => ({ ok: false, error: 'site must be one of "bilibili", "youtube".' }) }); + await ext.opened; + await until(() => s.bridge.connected); + const r = await s.client.callTool({ name: "search_videos", arguments: { query: "x", site: "vimeo" } }); + assert.equal(r.isError, true); + assert.equal(r.content[0].text, 'site must be one of "bilibili", "youtube".'); + assert.deepEqual(ext.messages.find((m) => m.method).params, { site: "vimeo", query: "x", page: 1 }); + assert.equal(formatSearch({ site: "demo", query: "x", page: 1, items: [] }), 'No demo videos for "x" on page 1.'); + ext.ws.close(); + await s.close(); +}); + test("search_videos: empty is not an error, THROTTLED says stop", async () => { assert.equal(formatSearch({ site: "youtube", query: "zzz", page: 1, items: [] }), 'No YouTube videos for "zzz" on page 1.'); const s = await setup(); From 3a655b1ff5a060cbddf5f2fccccc9391fb0e828c Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:07:40 +0800 Subject: [PATCH 04/37] =?UTF-8?q?AGENT.md=EF=BC=9A=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E7=BA=A6=E5=AE=9A=EF=BC=88?= =?UTF-8?q?=C2=A75=EF=BC=89=E5=92=8C=20NO=5FACCESS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 25b6e34a..4e799604 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -2,7 +2,7 @@ How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client, which can also search for videos (§2 `search`). The page messages cannot search. Read-only. Never API keys or AI conversations. The user's own Bilibili favorites, follows and watch history, with their 分拣 tags, notes and AI verdicts, only through the MCP server and only while the user allows it (§2, `favorites` / `follows` / `history`). -Supported pages: Bilibili video pages (`www.bilibili.com/video/…`, `/bangumi/play/…`, `/list/…`, `/festival/…`) and YouTube watch pages (`www.youtube.com/watch?v=…`). +Supported pages: Bilibili video pages (`www.bilibili.com/video/…`, `/bangumi/play/…`, `/list/…`, `/festival/…`) and YouTube watch pages (`www.youtube.com/watch?v=…`). The MCP server reads the sites registered in `extension/agent-sites/` (§5); the page messages only these two. ## 1. Page messages (what the skill uses) @@ -64,13 +64,15 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: |---|---|---| | `get` | `{ url }` | Same `data` as `moondigest-get`, for the video at `url` | | `frame` | `{ url, at, maxWidth? }` | Same `data` as `moondigest-frame` | -| `search` | `{ site, query, page? }` | A video search on `site` (`"bilibili"` \| `"youtube"`), `page` default 1; see below | +| `search` | `{ site, query, page? }` | A video search on `site` (a §5 site id whose adapter has `search`: `"bilibili"`, `"youtube"`), `page` default 1; see below | | `favorites` | `{ folderId?, page?, query? }` | The user's Bilibili favorites, see below | | `follows` | `{ page?, query? }` | The UP 主 the user follows, see below | | `history` | `{ cursor? }` | The user's Bilibili watch history, see below | `get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. +A site whose adapter reads the page itself (§5, `read` a function) answers `get` only, and only in the extension's own tab: the user's tabs are never used. It fails with `NO_ACCESS` until the user has allowed the site's origins. Its `data` is what `read` returned, with `site` set to the site's id. + `search` opens no tab: the service worker fetches with the user's cookies. Bilibili: the WBI-signed `api.bilibili.com/x/web-interface/wbi/search/type?search_type=video` through the same channel as 分拣台 (`triageBiliGet`). YouTube: the `www.youtube.com/results?search_query=…` page, read from its `ytInitialData`; only the result list's own videos, no shorts, ads, channels, playlists or shelves. YouTube has page 1 only (about 20 videos); a `page` above 1 is an error. Searches run one at a time, at least 2 s apart; one still queued at the call's deadline is dropped with `TIMEOUT`. HTTP 412, B站's risk-control codes and YouTube's 429 are `THROTTLED`. No results is `items: []`, not an error. `data`: `{ site, query, page, items }`, each item `{ site, videoId, url, title, author, duration, views, uploadDate, description }`: `url` is `https://www.bilibili.com/video/BV…` or `https://www.youtube.com/watch?v=…` (ready for `get`); `title`, `author`, `description` plain text (B站's `` highlights and HTML entities removed), `description` at most 200 characters; `duration` seconds, `0` when unknown (a live stream); `views` a number or `null`; `uploadDate` B站 `YYYY-MM-DD` (local day), YouTube as the page writes it ("2 years ago", in the browser's language), `""` when missing. @@ -87,12 +89,40 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ - `extension/agent-page.js` (content script): `MdgAgentPage.bind({ read, signal })`. `read()` is content.js's `agentRead`, the side panel's read of the page's video, resolving to §1's `get` data or rejecting with an `Error` carrying `code`. It answers the page messages and the bridge's `chrome.runtime` messages `{ type: "agent-call", method, params }` (`params` without `url`) with `{ ok, data }` or `{ ok: false, error, code }`, and runs `frame` itself. - `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling. `MdgAgentBridge.addMethods({ name: (params) => reply })` adds methods answered without a tab. -- `extension/agent-search.js` (service worker): `MdgAgentSearch.search(params, deadline)`, §2's `search`. +- `extension/agent-search.js` (service worker): `MdgAgentSearch.search(params, deadline)`, §2's `search`: checks, the queue, then the site's adapter. +- `extension/agent-sites/` (service worker): the site registry and one adapter per site, §5. - `extension/agent-mydata.js` (service worker, loaded after the 分拣台's `triage-bg.js` and `follow-core.js`): §2's `favorites`, `follows`, `history`. - `mcp/server.js`: the MCP server, `npm test` in `mcp/`. ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. + +## 5. Sites + +Each site the MCP server reads is one adapter, `extension/agent-sites/.js`, registered with `extension/agent-sites/registry.js`. `agent-bridge.js` and `agent-search.js` ask the registry and name no site. Sites beyond Bilibili and YouTube get MCP tools only, no pages of their own. + +```js +MdgAgentSites.register({ + id: "demo", // [a-z0-9-]+, the MCP `site` value + origins: ["https://www.demo.com/*"], // match patterns: the user's tabs to look in, the host access to check + key(u) { // u: a URL; "itemId:part" for a page this site reads, else null + const m = /^\/v\/(\w+)$/.exec(u.pathname); + return u.hostname === "www.demo.com" && m ? `${m[1]}:` : null; + }, + read(url) { // "content", or a function run in the page's MAIN world (see below) + const s = window.__INITIAL_STATE__; + return s ? { url, title: s.title, author: s.author, duration: s.duration, description: s.desc, + chapters: [], subtitle: { segments: [], lang: "", warning: "", missing: "no subtitle" }, comments: [] } : null; + }, + search: async (query, page) => [], // optional: §2's search items, throw an Error with code THROTTLED when rate-limited +}); +``` + +- `key`: the registry prefixes the id, so two URLs of one video must give the same `itemId:part`. Two keys equal means the same video for tab reuse. +- `read: "content"`: a content script (`agent-page.js`) answers in the page; Bilibili and YouTube only, it needs a manifest entry. +- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. +- Host access: the manifest grants only Bilibili and YouTube; other sites are in `optional_host_permissions` (every http(s) site), so a new site needs no manifest change. Until the user allows its origins the site answers `NO_ACCESS`. Today the only way to allow them is the side panel's grant for reading web pages, which allows every site at once; a per-site 允许 in 设置 is not built yet. +- A new site adds: `agent-sites/.js`, `agent-sites/.selftest.js` (`require("./registry.selftest.js").load(".js")` gives a registry with only that site loaded), one `importScripts("agent-sites/.js")` line in `background.js`, and its name in the MCP tools' descriptions (`mcp/server.js`). A `search` that reads `MdgErrors.CODE` also needs a line in `entry-matrix.selftest.js`'s `NOT_ENTRIES`. From b2547e6684f56c90a16b52115fe295ba894af796 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:15:13 +0800 Subject: [PATCH 05/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=E5=8A=A0?= =?UTF-8?q?=E5=B0=8F=E5=AE=87=E5=AE=99=E5=8D=95=E9=9B=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从页面 __NEXT_DATA__ 读标题、节目/主播、时长、发布日期、简介和热评。 文稿只给登录的 App 用户,页面里没有,字幕留空,缺字幕原因里给出音频地址, agent 可以拿去转写。自检用一份真实单集页数据(裁剪过)测解析。 --- extension/agent-sites/xiaoyuzhou.js | 34 +++++++++++++++++ extension/agent-sites/xiaoyuzhou.selftest.js | 39 ++++++++++++++++++++ extension/background.js | 1 + 3 files changed, 74 insertions(+) create mode 100644 extension/agent-sites/xiaoyuzhou.js create mode 100644 extension/agent-sites/xiaoyuzhou.selftest.js diff --git a/extension/agent-sites/xiaoyuzhou.js b/extension/agent-sites/xiaoyuzhou.js new file mode 100644 index 00000000..a7e33b04 --- /dev/null +++ b/extension/agent-sites/xiaoyuzhou.js @@ -0,0 +1,34 @@ +// 小宇宙 (Xiaoyuzhou) podcast episodes for the agent interface (docs/AGENT.md §5), read from the page's __NEXT_DATA__. +// The transcript is not in the page (the app serves it to logged-in users only), so get says so and gives the audio URL. +MdgAgentSites.register({ + id: "xiaoyuzhou", + origins: ["*://*.xiaoyuzhoufm.com/*"], + key(u) { + const m = /^\/episode\/([0-9A-Za-z]+)\/?$/.exec(u.pathname); + return u.hostname.replace(/^www\./, "") === "xiaoyuzhoufm.com" && m ? `${m[1]}:` : null; + }, + // A plain function, not a method: executeScript serializes it with toString(). + read: function (url) { + const p = window.__NEXT_DATA__?.props?.pageProps; + const e = p?.episode; + // Another episode's data (a client-side navigation) counts as not ready. + if (!e || new URL(url).pathname.replace(/\/$/, "") !== `/episode/${e.eid}`) return null; + const audio = e.enclosure?.url || e.media?.source?.url || ""; + return { + url, + videoId: e.eid, + title: e.title || "", + author: [e.podcast?.title, e.podcast?.author].filter(Boolean).join(" / "), + uploadDate: (e.pubDate || "").slice(0, 10), + duration: e.duration || 0, + description: e.description || "", + part: { index: 1, count: 0, title: "" }, + chapters: [], + subtitle: { + segments: [], lang: "", warning: "", + missing: "Xiaoyuzhou shows transcripts only to logged-in app users." + (audio ? ` Audio to transcribe: ${audio}` : "") + }, + comments: (p.comments || []).map((c) => ({ uname: c.author?.nickname || "", like: c.likeCount || 0, message: c.text || "" })) + }; + } +}); diff --git a/extension/agent-sites/xiaoyuzhou.selftest.js b/extension/agent-sites/xiaoyuzhou.selftest.js new file mode 100644 index 00000000..f1094d03 --- /dev/null +++ b/extension/agent-sites/xiaoyuzhou.selftest.js @@ -0,0 +1,39 @@ +// node extension/agent-sites/xiaoyuzhou.selftest.js +const assert = require("assert"); +const vm = require("vm"); +const S = require("./registry.selftest.js").load("xiaoyuzhou.js"); + +const keys = { + "https://www.xiaoyuzhoufm.com/episode/6a7726aac4079d62c57f5185": "xiaoyuzhou:6a7726aac4079d62c57f5185:", + "https://xiaoyuzhoufm.com/episode/6a7726aac4079d62c57f5185/?s=share": "xiaoyuzhou:6a7726aac4079d62c57f5185:", + "https://www.xiaoyuzhoufm.com/podcast/5e280fab418a84a0461faa3c": null, + "https://www.xiaoyuzhoufm.com/": null, + "https://www.xiaoyuzhoufm.com.evil.test/episode/6a7726aac4079d62c57f5185": null +}; +for (const [href, key] of Object.entries(keys)) assert.strictEqual(S.keyOf(href), key, href); + +// A real episode page's __NEXT_DATA__, trimmed to the fields read (2026-11-09). +const NEXT = { props: { pageProps: { + episode: { eid: "6a7726aac4079d62c57f5185", title: "No.229 对话少数派老麦:从 Palm、WebOS 到 AI 硬件,依然不想成为多数派", duration: 3630, + pubDate: "2026-08-11T12:00:00.000Z", enclosure: { url: "https://media.xyzcdn.net/5e280fab418a84a0461faa3c/luf34bdBuAypd09__G30J1XhwWCW.m4a" }, + description: "嘉宾|老麦,少数派创始人。\n主播|刘飞,内容创作者,产品经理。", podcast: { title: "三五环", author: "后厂村的刘飞" } }, + comments: [{ author: { nickname: "早起川" }, likeCount: 9, text: "Palm WebOS 这个词居然还能被看到!" }] +} } }; + +// Run read the way executeScript does: from its source, in a world with only the page's globals. +const readIn = (win) => vm.runInNewContext(`(${S.get("xiaoyuzhou").read})`, { window: win, URL }); +const url = "https://www.xiaoyuzhoufm.com/episode/6a7726aac4079d62c57f5185"; +const d = JSON.parse(JSON.stringify(readIn({ __NEXT_DATA__: NEXT })(url))); // out of the vm realm, as executeScript returns it +assert.strictEqual(d.videoId, "6a7726aac4079d62c57f5185"); +assert.strictEqual(d.title, NEXT.props.pageProps.episode.title); +assert.strictEqual(d.author, "三五环 / 后厂村的刘飞"); +assert.strictEqual(d.uploadDate, "2026-08-11"); +assert.strictEqual(d.duration, 3630); +assert.match(d.description, /^嘉宾/); +assert.deepStrictEqual(d.subtitle.segments, []); +assert.match(d.subtitle.missing, /Audio to transcribe: https:\/\/media\.xyzcdn\.net\/.+\.m4a$/); +assert.deepStrictEqual(d.comments, [{ uname: "早起川", like: 9, message: "Palm WebOS 这个词居然还能被看到!" }]); +// Not ready: no data yet, or another episode's. +assert.strictEqual(readIn({})(url), null); +assert.strictEqual(readIn({ __NEXT_DATA__: NEXT })("https://www.xiaoyuzhoufm.com/episode/other"), null); +console.log("agent-sites xiaoyuzhou selftest ok"); diff --git a/extension/background.js b/extension/background.js index c78f508f..eec6382b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,6 +4,7 @@ importScripts("common.js", "errors.js", "limits.js", "settings.js", "sites.js", // The sites the agent interface reads (docs/AGENT.md §5), one line each. importScripts("agent-sites/bilibili.js"); importScripts("agent-sites/youtube.js"); +importScripts("agent-sites/xiaoyuzhou.js"); // The agent interface's MCP socket (agent-bridge.js, docs/AGENT.md §2): the local moondigest-mcp server (mcp/) asks // for a video, the bridge opens or reuses its tab and asks agent-page.js there. Off until From 616fff9172165c5e15ddd48f763bbd89391d4fba Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:15:28 +0800 Subject: [PATCH 06/37] =?UTF-8?q?MCP=EF=BC=9AAI=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=83=BD=E6=94=B9=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=8C=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=BB=93=E6=9E=9C=E7=9B=B4=E6=8E=A5=E5=86=99=E3=80=81?= =?UTF-8?q?=E5=8F=AF=E6=92=A4=E9=94=80=EF=BC=8CB=E7=AB=99=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E5=8F=AA=E6=8F=90=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两个 MCP 工具,对应扩展的 triage、propose 方法(agent-writes.js),默认关,设置页「允许 MCP 连接」下面加从属开关「允许 AI 工具改分拣台」(mcpWrites),关着返回 WRITES_OFF。参数在扩展里整份先查(bvid、收藏夹 id、长度、标签只打在勾选收藏夹自己的视频上),一处不对就什么都不写,返回 BAD_PARAMS。 - set_triage:一次最多 50 个视频,写 AI 判断(存成细看结果,source: agent,卡片标「AI 工具判断」)、保留、备注和收藏夹标签。每个键都走分拣台自己用的那把锁读改写(MdgLimits.update、saveVideoNote、triage_tags 的 keyLock),和打开着的分拣台交错写也不丢。 - propose_fav_change:取消收藏、移动、复制只进待确认队列(triage_agent_proposals,最多 20 条),不碰 B站。分拣台进度胶囊显示「AI 工具的 N 条收藏夹提议等你确认」,「查看」打开来源收藏夹列出视频,确认后走分拣台原有的 unfavMany / transfer(限流、U 撤销照旧),「拒绝」删掉,Esc 留着;有 B站批量在跑时不让确认。不靠 MCP 客户端的权限弹框。 撤销的做法:每次 set_triage 记一条日志(triage_agent_log,最多 20 条),存每个值改前改后。分拣台把每条日志当成 U 栈里的一步(切换收藏夹也保留),按 U 让后台把仍是 AI 工具写的那个值改回去,用户之后又改过的不动并说明几个。日志先取走再还原,同一步不会被撤两次;还原失败放回日志。没选「只加标记」:标记只能让用户自己一个个改回去,算不上撤销;没把撤销放页面里做:页面关着时 AI 工具也在写,日志在存储里才不丢。 测试:agent-writes.selftest 覆盖开关、非法参数整份拒绝、写入和撤销、撤销跳过用户改过的、标签名额、和分拣台页面交错写、提议的各种拒绝和上限;triage.selftest 覆盖 U 步骤和提议的执行、拒绝、Esc、批量在跑时拒绝;mcp 测试覆盖两个工具的透传和文字。 --- docs/AGENT.md | 12 +- docs/ARCHITECTURE.md | 2 +- docs/TRIAGE.md | 6 + extension/agent-mydata.selftest.js | 1 + extension/agent-writes.js | 253 ++++++++++++++++++++++++++++ extension/agent-writes.selftest.js | 229 +++++++++++++++++++++++++ extension/background.js | 5 +- extension/limits.js | 13 +- extension/limits.selftest.js | 2 +- extension/messages.js | 2 + extension/options.html | 4 + extension/options.js | 5 +- extension/options.selftest.js | 3 +- extension/settings.js | 2 + extension/triage/fav-base.js | 16 +- extension/triage/fav-folders.js | 2 +- extension/triage/fav-page.js | 16 +- extension/triage/fav-render.js | 3 + extension/triage/fav-writes.js | 86 +++++++++- extension/triage/shared.js | 8 +- extension/triage/triage.selftest.js | 91 ++++++++++ mcp/README.md | 18 ++ mcp/server.js | 66 +++++++- mcp/test/server.test.mjs | 25 ++- 24 files changed, 835 insertions(+), 35 deletions(-) create mode 100644 extension/agent-writes.js create mode 100644 extension/agent-writes.selftest.js diff --git a/docs/AGENT.md b/docs/AGENT.md index 25b6e34a..a165d4f3 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -1,6 +1,6 @@ # MoonDigest agent interface v1 -How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client, which can also search for videos (§2 `search`). The page messages cannot search. Read-only. Never API keys or AI conversations. The user's own Bilibili favorites, follows and watch history, with their 分拣 tags, notes and AI verdicts, only through the MCP server and only while the user allows it (§2, `favorites` / `follows` / `history`). +How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client, which can also search for videos (§2 `search`). The page messages cannot search. Never API keys or AI conversations. The user's own Bilibili favorites, follows and watch history, with their 分拣 tags, notes and AI verdicts, only through the MCP server and only while the user allows it (§2, `favorites` / `follows` / `history`). Writes only through the MCP server, only to the 分拣台, and only while the user allows them (§2, `triage` / `propose`): local results directly, Bilibili changes as proposals the user confirms. Supported pages: Bilibili video pages (`www.bilibili.com/video/…`, `/bangumi/play/…`, `/list/…`, `/festival/…`) and YouTube watch pages (`www.youtube.com/watch?v=…`). @@ -68,6 +68,8 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: | `favorites` | `{ folderId?, page?, query? }` | The user's Bilibili favorites, see below | | `follows` | `{ page?, query? }` | The UP 主 the user follows, see below | | `history` | `{ cursor? }` | The user's Bilibili watch history, see below | +| `triage` | `{ items }` | Writes the 分拣台's local results, see below | +| `propose` | `{ action, from, to?, bvids, reason? }` | Queues a Bilibili change for the user to confirm in the 分拣台, see below | `get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. @@ -81,6 +83,11 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: - `follows`: `{ source, total, page, hasMore, items: [{ mid, name, sign, groups, tags? }] }`, 50 a page. With a synced 关注 list (the 分拣台's 关注) it is read from there (`source: "local"`, with the user's UP 标签 as `tags`, and `query` matched on name, sign, groups and tags); else one Bilibili page of the followings plus the group names (`source: "bilibili"`, `query` is not applied and `queryIgnored: true`). `groups` are 关注分组 names, 特别关注 and 默认分组 included. - `history`: `{ items: [{ bvid?, url, title, author, viewAt, progress, duration, kind }], cursor }`, 30 a call, newest first. `cursor` is opaque; pass it back for the next page, `""` at the end. `kind` is Bilibili's business (`archive` for a video, also `pgc`, `live`, `article`…); `progress` is seconds, -1 finished; `viewAt` seconds since the epoch. +`triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → 高级 → 允许 AI 工具改分拣台 (`mcpWrites`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `WRITES_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). + +- `triage`: `items`, 1 to 50 of `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`, each bvid once; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` (≤ 100 chars); `reason` ≤ 300, `points` ≤ 3 of ≤ 200; they are stored as a 细看 result (`triage_analysis_`, `source: "agent"`). `kept` sets or clears the 保留 mark; `note` (≤ 2000, `""` removes it) the 备注; `addTags` / `removeTags` (≤ 10 names each) the tags of folder `folderId`, which must be ticked and whose local copy holds the video. A missing tag is made while the folder is under 收藏夹设置's tag cap; the others are answered in `tagsNotMade`. Each key is read-modify-written under the lock the 分拣台 takes for it (`MdgLimits.update`, `saveVideoNote`, `keyLock(triage_tags)` for the tag list and map), so neither side loses the other's write. The call is journaled (`triage_agent_log`, the last 20) with each value before and after; the 分拣台 shows each entry as a U step and its U (`agent-undo`) puts back each value still as the tool left it, leaving those the user changed since. `data`: `{ changed, tagsNotMade? }`. +- `propose`: `action` `unfav`, `move` or `copy`; `from` a folder ticked in the 分拣台 (or `toview`) whose local copy holds every one of `bvids` (1 to 50); `to`, for `move` and `copy` only, another folder of the list the 分拣台 last read (`triage_folder_list`), never `toview`; `reason` ≤ 200. Nothing is sent to Bilibili: the proposal is queued (`triage_agent_proposals`, at most 20, else `PROPOSALS_FULL`). The 分拣台's progress pill shows it; 查看 opens `from` and asks with the videos still there, 执行 runs the 分拣台's own 取消收藏 / 移动 / 复制 (its rate limits and undo), 拒绝 drops it, Esc leaves it. `data`: `{ id, status: "proposed" }`. + Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answers `{ type: "pong" }` (WebSocket traffic keeps an MV3 service worker alive in Chrome 116+). ## 3. Code @@ -89,10 +96,11 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ - `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling. `MdgAgentBridge.addMethods({ name: (params) => reply })` adds methods answered without a tab. - `extension/agent-search.js` (service worker): `MdgAgentSearch.search(params, deadline)`, §2's `search`. - `extension/agent-mydata.js` (service worker, loaded after the 分拣台's `triage-bg.js` and `follow-core.js`): §2's `favorites`, `follows`, `history`. +- `extension/agent-writes.js` (service worker, after `triage-bg.js`): §2's `triage`, `propose`, and the 分拣台's `agent-undo`. - `mcp/server.js`: the MCP server, `npm test` in `mcp/`. ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `WRITES_OFF` (the same for `triage` / `propose`: 允许 AI 工具改分拣台), `BAD_PARAMS` (a parameter of `triage` / `propose` is wrong; nothing was written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38e43add..104f7c08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 两种模式都画的东西,只在 `extension/triage/shared.js`(`TriageUi`)里写一份:标题行、排序、搜索框、筛选片、第 3/4 行的行名(`labeledRow`)、第 4 行的标签片(`tagFilterChip`)、「已出分拣范围」的原因页签(`kindTabs`)、卡片上的「原在…」(`originChip`)、「清理」按钮(`cleanBtn`)和清理前的备份说法(`CLEAN_BACKUP`)、「AI 刚打的」片(`aiRecentChip`)、选中栏(`selbar`)、移动 / 复制弹窗(`askTransfer`:收藏夹传收藏夹,关注传分组)、左栏项(`sideItem`)、刷新和导出按钮、进度胶囊、空状态和里面的刷新(`refreshEmpty`)、头部收起(`headFold`,两种模式各自记在 `triage_head_folded`;列表滚动后加 `head-scrolled` 画头部下沿的阴影;它返回的函数由 `follow.js` 的 `setMode` 在切换 `body.follow-mode` 后立刻调用,新模式的收起状态不播动画地生效)、列表为空的原因(`noMatch`)、撤销前的确认(`undoAsk`)、设置行(`fillSetRows`,包括「AI 打标签每批数量」,数字框的范围和「默认 N」、提示里的数字(`data-num-min` / `data-num-max` / `data-num-default`)都来自 `NUM_SETTINGS`;提示里的数字由 `fillNumHints` 填,设置页 `options.html` 也加载 `shared.js` 用它;提示里引用的固定数(粗看一次请求带几个标题 `TriageUi.TITLES_PER_REQUEST`、粗看和 AI 打标签带的简介字数 `INTRO_CHARS`、细看没字幕时读的热门评论条数 `DEEP_COMMENTS`、笔记的热门评论条数、快捷追问和正文附加段落的上限、AI 读的字幕字数)写成 `data-hint-num`,也由 `fillNumHints` 从代码填)、输出上限留空时的「自动」(`autoMaxTokens`:粗看、AI 打标签、细看各一条公式,后台 `triageMaxTokens` 发请求和收藏夹设置的提示都用;AI 打标签读粗看的输出上限设置)、格式化函数、AI 建议的合并(标签名清洗和标签颜色在 `extension/tag-core.js`)。「收藏夹设置」和「关注设置」两个弹窗的结构都写在 `triage.html` 里。发现两边各写了一份,就合并进去。 - 打标签面板只有一个:`extension/triage/tag-picker.js`。例外:B站 页面上的「+」标签面板(`badges.js`)在封闭的 shadow DOM 里,用不了 tag-picker.js 和扩展页的样式,样子和键盘照它另写一份;标签的纯函数(颜色、标签名清洗、新标签 id、下一个颜色、面板的行、编号、顺序和方向键)放在 `extension/tag-core.js`(`MdgTagCore`)里,`badges.js`、`shared.js`、`follow.js` 和 `fav-*.js` 都用它,manifest 和 `triage.html` 都在它们之前加载。`tag-core.selftest.js` 会查别处有没有再写一份。「标签管理」和「✦ AI 打标签」弹窗也各只有一个:`extension/triage/tag-dialogs.js`;关注的「分组管理」也用「标签管理」这个弹窗,adapter 换掉标题、文字和每行的样子。模式打开它们时只传一个 adapter,里面是单位(视频 / UP 主 / 记录)、上限和数据,文字、布局和确认页的计数(改动数、按标签汇总、「用在 N 个」)都在弹窗里。摘读记录页也用这三个(打标签面板、「标签管理」、「✦ AI 打标签」):页面里只放空的 ``,「✦ AI 打标签」的结构由 `tag-dialogs.js` 写进去,两个页面共用一份;摘读记录没有 T,确认页和「AI 刚打的」提示里「不对的按 T 改」那半句由 adapter 的 `check` 换掉。 -- 标签的数据操作只有一份:`extension/triage/tag-store.js`(`MdgTagStore`),`triage.html` 和 `history.html` 都加载它。新建、改名、换颜色、改说明、删除(确认框的个数)、给条目加减标签、上限和原因、AI 的新建名额(`room` / `noRoom`)、U 的步骤(`pushStep` / `undoLast`,收藏夹的保留和改 B站 的步骤也走这两个)、「✦ AI 打标签」的批次(`runAi`)和「应用」(`apply`:先写,写成了才清建议、标「AI 刚打的」、压 U;写失败留着建议、红字说)都在里面。每个页面 `MdgTagStore.create(adapter)` 一次,adapter 只说不一样的:数据在哪(三种后端:收藏夹 `local`,内存加页面自己的 `store`;关注 `follow`,`MdgLimits.updateFollow` 的锁;摘读记录 `marks`,`MdgLimits.updateReadingMarks`,星标也走它的 `update`)、条目(哪些 `live`;关注的已取消关注算进个数 `uses`,U 不改他们 `restorable`)、作用域(收藏夹按收藏夹,`scoped`)、上限和超了怎么办(`hardCap`:摘读记录给出「摘读记录的标签」,超了不建并在确认页说;收藏夹和关注不给,照建)、名词。删掉一个标签(标签管理、U 删掉没人用的新标签)都调页面的 `tagGone`,收藏夹在别的标签页删了标签时(`followShared`)也走它;关注的后端在删标签的同一次写里把它从已取消关注 UP 主的 `follow_unfollowed` 记录上去掉。`conventions.selftest.js` 的 5c 查页面文件不直接写标签键、不自建撤销栈。B站 页面(`badges.js`)和后台不用它,用 `tag-core.js` 的 `applyUpTag`。 +- 标签的数据操作只有一份:`extension/triage/tag-store.js`(`MdgTagStore`),`triage.html` 和 `history.html` 都加载它。新建、改名、换颜色、改说明、删除(确认框的个数)、给条目加减标签、上限和原因、AI 的新建名额(`room` / `noRoom`)、U 的步骤(`pushStep` / `undoLast`,收藏夹的保留和改 B站 的步骤也走这两个)、「✦ AI 打标签」的批次(`runAi`)和「应用」(`apply`:先写,写成了才清建议、标「AI 刚打的」、压 U;写失败留着建议、红字说)都在里面。每个页面 `MdgTagStore.create(adapter)` 一次,adapter 只说不一样的:数据在哪(三种后端:收藏夹 `local`,在 `MdgLimits.keyLock(triage_tags)` 的锁里现读存储再改、经页面自己的 `store` 写回,页面里那份只是副本,后台替 AI 工具写标签(`agent-writes.js`)也用这把锁;关注 `follow`,`MdgLimits.updateFollow` 的锁;摘读记录 `marks`,`MdgLimits.updateReadingMarks`,星标也走它的 `update`)、条目(哪些 `live`;关注的已取消关注算进个数 `uses`,U 不改他们 `restorable`)、作用域(收藏夹按收藏夹,`scoped`)、上限和超了怎么办(`hardCap`:摘读记录给出「摘读记录的标签」,超了不建并在确认页说;收藏夹和关注不给,照建)、名词。删掉一个标签(标签管理、U 删掉没人用的新标签)都调页面的 `tagGone`,收藏夹在别的标签页删了标签时(`followShared`)也走它;关注的后端在删标签的同一次写里把它从已取消关注 UP 主的 `follow_unfollowed` 记录上去掉。`conventions.selftest.js` 的 5c 查页面文件不直接写标签键、不自建撤销栈。B站 页面(`badges.js`)和后台不用它,用 `tag-core.js` 的 `applyUpTag`;AI 工具经 MCP 打的标签由 `agent-writes.js` 在上面那把锁里写。 - 标签和这些弹窗的样式只有一份:`extension/triage/tags.css`(标签片、打标签面板、「标签管理」「✦ AI 打标签」和它们所在的 ``),`triage.html` 和 `history/history.html` 都在自己的样式表之后加载它。`` 的规则写成 `dialog:where(:not(.mdg-overwrite))`,和原来的 `dialog` 一样具体,又不碰 `tokens.css` 管的「该笔记已存在」。挪进来时分拣台每个元素的计算样式一项不差(页面、三个弹窗和分拣台的其他弹窗逐个核对过);摘读记录的打标签面板和「标签管理」因此和分拣台一样了。 - 摘读记录页的头部和卡片用分拣台的样子:`history.html` 在 `history.css` 之前加载 `triage/triage.css`,头部的行名、筛选片、`.act-btn`、全选、搜索框、选中栏、`.card`、`.badge` 和空状态都用它的规则和 `TriageUi` 的标记(`labeledRow`、`stateGroup`、`filterBtn`、`aiRecentChip`、`tagFilterChip`、`tagButtons`、`gearBtn`、`selectAllBox`、`selbar`、`actBtn`、`searchBox`、`bindSearch`、`resultCount`、`cardTagChip`、`emptyState`、`noMatch`);`history.css` 只写页面的排法、头部白卡和卡片里的列。分拣台的头部靠四行各自的 `::before` 拼成白卡(列表从底下滚过),摘读记录是窗口滚动、头部不收起,所以白卡就是一个 `
` 的边框,不用那套拼法。改 `triage.css` 里这些规则时两个页面一起看。 - 所有输入框和快捷键都要走 `extension/typing.js`(`MdgTyping`): diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 03310c7c..be657cac 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -68,6 +68,12 @@ 分拣台深色主题:同一个细看完成步骤、观看进度标记和展开的待播

+## AI 工具(MCP) + +在设置 → 高级打开「允许 AI 工具改分拣台」后,Claude Code、Cursor 这类 AI 工具可以通过 [MCP 服务](../mcp/README.md) 写入 AI 判断、保留、备注和视频标签。它写的判断卡片上标「AI 工具判断」;每次写入在分拣台里是一步,按 U 撤销,你之后又改过的不动。 + +取消收藏、移动和复制它只能提议。进度胶囊写「AI 工具的 N 条收藏夹提议等你确认」,点「查看」打开那个收藏夹、列出这些视频:确认后照分拣台自己的操作在 B站执行(按 U 撤销),点「拒绝」删掉提议,B站不变。 + ## 快捷键 | 按键 | 作用 | diff --git a/extension/agent-mydata.selftest.js b/extension/agent-mydata.selftest.js index 5457c34e..2cbd4116 100644 --- a/extension/agent-mydata.selftest.js +++ b/extension/agent-mydata.selftest.js @@ -15,6 +15,7 @@ assert.ok(order.indexOf("agent-mydata.js") > order.indexOf("triage/follow-core.j let M = null; for (const file of order) { if (file === "agent-mydata.js") ctx.MdgAgentBridge = { addMethods: (m) => (M = m) }; + if (file === "agent-writes.js") ctx.MdgAgentBridge = { addMethods() {} }; // agent-writes.selftest.js's vm.runInContext(fs.readFileSync(path.join(__dirname, file), "utf8"), ctx); } assert.deepStrictEqual(Object.keys(M), ["favorites", "follows", "history"]); diff --git a/extension/agent-writes.js b/extension/agent-writes.js new file mode 100644 index 00000000..89cdacc0 --- /dev/null +++ b/extension/agent-writes.js @@ -0,0 +1,253 @@ +// MoonDigest's agent interface, docs/AGENT.md §2: what an AI tool may change in the 分拣台, off until 设置 → 高级 → 允许 +// AI 工具改分拣台 (mcpWrites). +// - triage writes local results at once (AI 判断, 保留, 备注, 视频标签), each key read-modify-written under the lock the +// 分拣台 takes for it, and journals the change (KEYS.triageAgentLog): the 分拣台 offers it to U, and agent-undo puts back +// what is still as the tool left it. +// - propose changes nothing on B站: it queues a 取消收藏, 移动 or 复制 (KEYS.triageAgentProposals) that the user confirms +// or rejects in the 分拣台, whose own B站 writes (limits, undo) then run it. +// The worker loads it after triage-bg.js, whose helpers it uses. +(() => { + const K = MdgLimits.KEYS; + const ITEMS_MAX = 50; + const PROPOSALS_MAX = 20; + const NOTE_MAX = 2000; + const BVID = /^BV[0-9A-Za-z]{10}$/; + const ACTIONS = ["unfav", "move", "copy"]; + + const fail = (code, message) => Object.assign(new Error(message), code ? { code } : {}); + const bad = (message) => fail("BAD_PARAMS", message); + const text = (v, max, name) => { + if (typeof v !== "string" || v.length > max) throw bad(`${name} must be a string of at most ${max} characters.`); + return v.trim(); + }; + const listOf = (v, max, name) => { + if (!Array.isArray(v) || v.length > max) throw bad(`${name} must be a list of at most ${max}.`); + return v; + }; + const given = (x, k) => x[k] !== undefined; + const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + const tagNames = (v, name) => + listOf(v ?? [], 10, name).map((n, j) => { + const clean = MdgTagCore.cleanTagName(text(n, 40, `${name}[${j}]`)); + if (!clean) throw bad(`${name}[${j}] is not a tag name.`); + return clean; + }); + + // One entry of triage's items, checked whole before anything is written. + function itemOf(x, i) { + const at = `items[${i}]`; + if (!x || typeof x !== "object" || !BVID.test(x.bvid)) throw bad(`${at}.bvid must be a BV id (BV and 10 letters or digits).`); + const it = { bvid: x.bvid }; + if (given(x, "verdict")) { + if (!MdgTagCore.VERDICTS[x.verdict]) throw bad(`${at}.verdict must be keep, drop or unsure.`); + const summary = text(x.summary ?? "", 100, `${at}.summary`); + if (!summary) throw bad(`${at}.summary is required with a verdict.`); + const points = listOf(x.points ?? [], TRIAGE_POINTS, `${at}.points`).map((p, j) => text(p, 200, `${at}.points[${j}]`)).filter(Boolean); + // A 细看's shape (triageAnalyze), from the tool: the card names it by source and shows no basis line. + it.analysis = { bvid: x.bvid, status: "done", source: "agent", oneLiner: summary, points, verdict: x.verdict, reason: text(x.reason ?? "", 300, `${at}.reason`) }; + } else if (["summary", "reason", "points"].some((k) => given(x, k))) throw bad(`${at}: summary, reason and points go with a verdict.`); + if (given(x, "kept")) { + if (typeof x.kept !== "boolean") throw bad(`${at}.kept must be true or false.`); + it.kept = x.kept; + } + if (given(x, "note")) it.note = text(x.note, NOTE_MAX, `${at}.note`); + const add = tagNames(x.addTags, `${at}.addTags`); + const remove = tagNames(x.removeTags, `${at}.removeTags`); + if (add.length || remove.length) { + if (!triageFolderId(x.folderId)) throw bad(`${at}.folderId must be the folder whose tags these are (a folder id from get_favorites, or toview).`); + Object.assign(it, { folderId: String(x.folderId), add, remove }); + } + if (Object.keys(it).length === 1) throw bad(`${at} changes nothing.`); + return it; + } + + // The local results of up to ITEMS_MAX videos. Resolves { changed, tagsNotMade? }; what landed is journaled even when a + // later write fails. + async function triage({ items }) { + const list = listOf(items, ITEMS_MAX, "items").map(itemOf); + if (!list.length) throw bad("items must name at least one video."); + if (new Set(list.map((it) => it.bvid)).size < list.length) throw bad("Name each bvid once per call."); + const tagged = list.filter((it) => it.folderId); + const folders = [...new Set(tagged.map((it) => it.folderId))]; + if (folders.length) { + const got = await chrome.storage.local.get([K.triageIncludedFolders, ...folders.map(K.triageSnapshot)]); + const ticked = new Set((got[K.triageIncludedFolders] || []).map(String)); + const off = tagged.find((it) => !ticked.has(it.folderId) || !got[K.triageSnapshot(it.folderId)]?.bvids?.includes(it.bvid)); + if (off) throw bad(`${off.bvid} is not in folder ${off.folderId} as MoonDigest has it: a folder's tags go on its own videos, in folders ticked in the 分拣台.`); + } + const now = Date.now(); + const changes = {}; // part → { bvid: [before, after] } + const note = (part, b, was, next) => ((changes[part] ||= {})[b] = [was ?? null, next ?? null]); + const made = []; + const notMade = []; + try { + const keep = list.filter((it) => "kept" in it); + if (keep.length) { + await MdgLimits.update(K.triageKept, (cur = {}) => { + const next = { ...cur }; + for (const it of keep) { + const was = cur[it.bvid] || null; + const v = it.kept ? was || { action: "keep", at: now } : null; + if (same(was, v)) continue; + note("kept", it.bvid, was, v); + if (v) next[it.bvid] = v; + else delete next[it.bvid]; + } + return changes.kept ? next : undefined; + }); + } + for (const it of list.filter((x) => "note" in x)) { + const was = (await chrome.storage.local.get(K.triageNotes))[K.triageNotes]?.[it.bvid]?.text || ""; + if (await MdgLimits.saveVideoNote(it.bvid, it.note, undefined, was)) note("note", it.bvid, was, it.note); + } + for (const it of list.filter((x) => x.analysis)) { + const v = { ...it.analysis, analyzedAt: now }; + await MdgLimits.update(K.triageAnalysis(it.bvid), (was) => (note("analysis", it.bvid, was, v), v)); + } + if (tagged.length) { + const cap = TriageUi.numSetting("tagLimit", (await chrome.storage.sync.get("triageTagLimit")).triageTagLimit); + await MdgLimits.locked(MdgLimits.keyLock(K.triageTags), async () => { + const got = await chrome.storage.local.get([K.triageTags, K.triageVideoTags]); + let tags = got[K.triageTags] || []; + const map = { ...got[K.triageVideoTags] }; + const before = {}; + for (const it of tagged) { + const own = () => tags.filter((t) => t.folder === it.folderId); + const idOf = (name) => own().find((t) => t.name === name)?.id; + const add = []; + for (const name of it.add) { + let id = idOf(name); + // A new tag only while the folder has room (收藏夹设置's cap), as 标签管理 makes one. + if (!id && own().length < cap) { + const t = { id: MdgTagCore.newTagId("t"), name, color: MdgTagCore.nextTagColor(own()), folder: it.folderId }; + tags = [...tags, t]; + made.push(t.id); + id = t.id; + } + if (id) add.push(id); + else notMade.push(name); + } + const drop = new Set(it.remove.map(idOf)); + const was = map[it.bvid] || []; + const next = [...new Set([...was.filter((id) => !drop.has(id)), ...add])]; + if (same(was, next)) continue; + before[it.bvid] = was; + if (next.length) map[it.bvid] = next; + else delete map[it.bvid]; + } + if (!made.length && !Object.keys(before).length) return; + await chrome.storage.local.set({ [K.triageTags]: tags, [K.triageVideoTags]: map }); + for (const [b, was] of Object.entries(before)) note("tags", b, was, map[b] || []); + }); + } + } finally { + const n = new Set(Object.values(changes).flatMap(Object.keys)).size; + if (n || made.length) { + const entry = { id: MdgTagCore.newTagId("a"), at: now, n, changes, made }; + await MdgLimits.update(K.triageAgentLog, (log = []) => [...log, entry].slice(-MdgLimits.TRIAGE_UNDO_STEPS)); + } + } + const n = new Set(Object.values(changes).flatMap(Object.keys)).size; + return { changed: n, ...(notMade.length && { tagsNotMade: [...new Set(notMade)] }) }; + } + + // U in the 分拣台 on one journaled triage: each value still as the tool left it goes back; one changed since stays + // (skipped). The entry is taken first, so a second U on it (another tab) is refused; a failed write puts it back. + async function undo({ id }) { + let entry = null; + await MdgLimits.update(K.triageAgentLog, (log = []) => { + entry = log.find((e) => e.id === id) || null; + return entry ? log.filter((e) => e !== entry) : undefined; + }); + if (!entry) throw fail("", "这一步已经撤销过了"); + const { kept = {}, note = {}, analysis = {}, tags = {} } = entry.changes; + const skipped = new Set(); + try { + if (Object.keys(kept).length) { + await MdgLimits.update(K.triageKept, (cur = {}) => { + const next = { ...cur }; + for (const [b, [was, v]] of Object.entries(kept)) { + if (!same(cur[b], v)) skipped.add(b); + else if (was) next[b] = was; + else delete next[b]; + } + return next; + }); + } + for (const [b, [was, v]] of Object.entries(note)) { + if (!(await MdgLimits.saveVideoNote(b, was || "", undefined, v || ""))) skipped.add(b); + } + for (const [b, [was, v]] of Object.entries(analysis)) { + const key = K.triageAnalysis(b); + await MdgLimits.locked(MdgLimits.keyLock(key), async () => { + if (!same((await chrome.storage.local.get(key))[key], v)) return skipped.add(b); + if (was) await chrome.storage.local.set({ [key]: was }); + else await chrome.storage.local.remove(key); + }); + } + if (Object.keys(tags).length || entry.made.length) { + await MdgLimits.locked(MdgLimits.keyLock(K.triageTags), async () => { + const got = await chrome.storage.local.get([K.triageTags, K.triageVideoTags]); + const map = { ...got[K.triageVideoTags] }; + const live = new Set((got[K.triageTags] || []).map((t) => t.id)); + for (const [b, [was, v]] of Object.entries(tags)) { + if (!same(map[b] || [], v)) skipped.add(b); + else if (was.some((id) => live.has(id))) map[b] = was.filter((id) => live.has(id)); + else delete map[b]; + } + // Tags it made that nothing carries any more go too. + const used = new Set(Object.values(map).flat()); + const left = (got[K.triageTags] || []).filter((t) => !entry.made.includes(t.id) || used.has(t.id)); + await chrome.storage.local.set({ [K.triageTags]: left, [K.triageVideoTags]: map }); + }); + } + } catch (e) { + await MdgLimits.update(K.triageAgentLog, (log = []) => [...log, entry].sort((x, y) => x.at - y.at)).catch(() => {}); + throw e; + } + return { n: entry.n, skipped: skipped.size }; + } + + // A B站 write the user confirms in the 分拣台. from is a ticked folder (or 稍后再看) and every video in its local copy, + // so the page has their aids; to is a folder of the list the 分拣台 last read, not 稍后再看. + async function propose({ action, from, to, bvids, reason }) { + if (!ACTIONS.includes(action)) throw bad('action must be "unfav", "move" or "copy".'); + const src = String(from ?? ""); + if (!triageFolderId(src)) throw bad("from must be a folder id from get_favorites, or toview."); + const ids = listOf(bvids, ITEMS_MAX, "bvids"); + if (!ids.length || !ids.every((b) => BVID.test(b))) throw bad("bvids must be 1 to 50 BV ids."); + if (new Set(ids).size < ids.length) throw bad("Name each bvid once."); + const why = text(reason ?? "", 200, "reason"); + const got = await chrome.storage.local.get([K.triageIncludedFolders, K.triageSnapshot(src), K.triageFolderList]); + if (!(got[K.triageIncludedFolders] || []).map(String).includes(src)) throw bad(`Folder ${src} is not ticked in the 分拣台: the user can confirm changes only to ticked folders.`); + const have = new Set(got[K.triageSnapshot(src)]?.bvids || []); + const missing = ids.filter((b) => !have.has(b)); + if (missing.length) throw bad(`Not in folder ${src} as MoonDigest has it: ${missing.join(", ")}.`); + const p = { id: MdgTagCore.newTagId("p"), at: Date.now(), action, from: src, bvids: ids, ...(why && { reason: why }) }; + if (action !== "unfav") { + const dest = String(to ?? ""); + if (!triageNumId(dest) || dest === src) throw bad("to must be another folder id from get_favorites (not toview)."); + if (!(got[K.triageFolderList]?.folders || []).some((f) => String(f.id) === dest)) throw bad(`Folder ${dest} is not in the folder list MoonDigest last read.`); + p.to = dest; + } else if (to != null) throw bad("to goes with move and copy only."); + await MdgLimits.update(K.triageAgentProposals, (cur = []) => { + if (cur.length >= PROPOSALS_MAX) throw fail("PROPOSALS_FULL", `${PROPOSALS_MAX} proposals already wait for the user in the 分拣台. Ask the user to go through them first.`); + return [...cur, p]; + }); + return { id: p.id, status: "proposed" }; + } + + const on = async () => MdgSettings.normalizeSwitches({ mcpWrites: (await chrome.storage.sync.get("mcpWrites")).mcpWrites }).mcpWrites; + const OFF = "The user has not allowed AI tools to change the 分拣台. Ask the user to turn on 设置 → 高级 → 允许 AI 工具改分拣台 (Settings → Advanced) in MoonDigest, then call again."; + const method = (fn) => async (params) => { + try { + if (!(await on())) throw fail("WRITES_OFF", OFF); + return { ok: true, data: await fn(params && typeof params === "object" ? params : {}) }; + } catch (e) { + return { ok: false, error: String(e?.message || e), ...(e?.code ? { code: String(e.code) } : {}) }; + } + }; + MdgAgentBridge.addMethods({ triage: method(triage), propose: method(propose) }); + // U works whatever the switch says now: the user takes back what was written while it was on. + if (globalThis.chrome?.runtime?.onMessage) triageListen("", { "agent-undo": (msg) => undo(msg) }); +})(); diff --git a/extension/agent-writes.selftest.js b/extension/agent-writes.selftest.js new file mode 100644 index 00000000..bb53fddc --- /dev/null +++ b/extension/agent-writes.selftest.js @@ -0,0 +1,229 @@ +// /usr/local/bin/node extension/agent-writes.selftest.js +if (process.versions.bun) (console.error("This selftest needs Node: it fails or hangs under bun (see 自检 in CONTRIBUTING.md)."), process.exit(1)); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +// One storage and one lock manager for the worker and a 分拣台 page (its tag backend, tag-store.js), each call a tick +// late so overlapping writes really interleave. +const plain = (x) => JSON.parse(JSON.stringify(x)); +const tick = () => new Promise((r) => setImmediate(r)); +let local = {}; +let sync = {}; +const pick = (store, keys) => { + if (keys == null) return plain(store); + if (typeof keys === "string") keys = [keys]; + if (Array.isArray(keys)) return Object.fromEntries(keys.filter((k) => k in store).map((k) => [k, plain(store[k])])); + return Object.fromEntries(Object.entries(keys).map(([k, d]) => [k, k in store ? plain(store[k]) : d])); +}; +const listeners = []; +const chrome = { + storage: { + local: { + get: async (k) => (await tick(), pick(local, k)), + set: async (o) => (await tick(), Object.assign(local, plain(o))), + remove: async (k) => (await tick(), [].concat(k).forEach((x) => delete local[x])) + }, + sync: { get: async (k) => pick(sync, k) } + } +}; +const held = new Map(); +const navigator = { locks: { request: (name, fn) => { const run = (held.get(name) || Promise.resolve()).then(fn); held.set(name, run.catch(() => {})); return run; } } }; + +// The worker's load order, with the bridge's addMethods caught so the methods can be called directly; chrome.runtime +// from agent-writes.js on (triage-bg.js would register its worker listeners with it). +const ctx = vm.createContext({ TextEncoder, URL, URLSearchParams, console, setTimeout, clearTimeout, setInterval, clearInterval, AbortController, chrome, navigator, structuredClone }); +const order = [...fs.readFileSync(path.join(__dirname, "background.js"), "utf8").matchAll(/importScripts\(([^)]*)\)/g)].flatMap((m) => m[1].match(/"[^"]+"/g).map((f) => JSON.parse(f))); +assert.ok(order.indexOf("agent-writes.js") > order.indexOf("triage/triage-bg.js"), "agent-writes.js loads after triage-bg.js"); +let M = null; +for (const file of order) { + if (file === "agent-writes.js") { + ctx.MdgAgentBridge = { addMethods: (m) => (M = { ...M, ...m }) }; + chrome.runtime = { onMessage: { addListener: (l) => listeners.push(l) }, getURL: (p) => `chrome-extension://x/${p}` }; + } else if (file === "agent-mydata.js") ctx.MdgAgentBridge = { addMethods() {} }; + vm.runInContext(fs.readFileSync(path.join(__dirname, file), "utf8"), ctx); +} +assert.deepStrictEqual(Object.keys(M), ["triage", "propose"]); +const K = ctx.MdgLimits.KEYS; +// U from the 分拣台: the message a page of the extension sends; a B站 page's content script is refused. +const undo = (id, url = "chrome-extension://x/triage/triage.html") => new Promise((r) => listeners.at(-1)({ type: "agent-undo", id }, { url }, r)); + +// A 收藏夹 page's tag backend over the same storage, its copy updated only by its own writes (no storage events here). +const page = vm.createContext({ chrome, navigator, console, structuredClone, setTimeout, document: { getElementById: () => null } }); +page.globalThis = page; +for (const f of ["common.js", "errors.js", "limits.js", "settings.js", "tag-core.js", "triage/shared.js", "triage/runs.js", "triage/tag-picker.js", "triage/tag-store.js"]) { + vm.runInContext(fs.readFileSync(path.join(__dirname, f), "utf8"), page); +} + +const A = "BV1aaaaaaaaa"; +const B = "BV1bbbbbbbbb"; +const C = "BV1ccccccccc"; +const cases = []; +const check = (name, fn) => cases.push([name, fn]); +const reset = () => { + local = { + [K.triageIncludedFolders]: [11, "toview"], + [K.triageSnapshot("11")]: { bvids: [A, B], items: [] }, + [K.triageSnapshot("toview")]: { bvids: [C], items: [] }, + [K.triageFolderList]: { mid: 1, folders: [{ id: 11, title: "Rust" }, { id: 22, title: "杂" }] }, + [K.triageTags]: [{ id: "t1", name: "入门", color: "#111", folder: "11" }], + [K.triageVideoTags]: { [B]: ["t1"] } + }; + sync = { mcpWrites: true }; +}; +const log = () => local[K.triageAgentLog] || []; + +check("switch off: WRITES_OFF for both methods, nothing written", async () => { + sync = {}; + const before = plain(local); + for (const [name, p] of [["triage", { items: [{ bvid: A, kept: true }] }], ["propose", { action: "unfav", from: "11", bvids: [A] }]]) { + const r = await M[name](p); + assert.deepStrictEqual([r.ok, r.code], [false, "WRITES_OFF"], name); + assert.match(r.error, /允许 AI 工具改分拣台/); + } + assert.deepStrictEqual(local, before); +}); + +check("triage refuses bad input whole, before writing anything", async () => { + const before = plain(local); + const refused = [ + {}, + { items: [] }, + { items: Array.from({ length: 51 }, () => ({ bvid: A, kept: true })) }, + { items: [{ bvid: "BV1", kept: true }] }, + { items: [{ bvid: A }] }, + { items: [{ bvid: A, kept: true }, { bvid: A, note: "x" }] }, + { items: [{ bvid: A, kept: "yes" }] }, + { items: [{ bvid: A, verdict: "keep" }] }, + { items: [{ bvid: A, verdict: "maybe", summary: "x" }] }, + { items: [{ bvid: A, summary: "只有总结" }] }, + { items: [{ bvid: A, verdict: "keep", summary: "x".repeat(101) }] }, + { items: [{ bvid: A, note: 5 }] }, + { items: [{ bvid: A, addTags: ["新"] }] }, // no folder + { items: [{ bvid: A, addTags: ["新"], folderId: "22" }] }, // not ticked + { items: [{ bvid: C, addTags: ["新"], folderId: "11" }] }, // not in that folder + { items: [{ bvid: A, addTags: ["、"], folderId: "11" }] }, + // A good item does not get through beside a bad one. + { items: [{ bvid: A, kept: true }, { bvid: B, verdict: "keep" }] } + ]; + for (const p of refused) { + const r = await M.triage(p); + assert.deepStrictEqual([r.ok, r.code], [false, "BAD_PARAMS"], JSON.stringify(p).slice(0, 80)); + } + assert.deepStrictEqual(local, before); +}); + +check("triage writes verdict, 保留, note and tags, journals it, and U puts all of it back", async () => { + local[K.triageAnalysis(B)] = { bvid: B, status: "done", verdict: "drop", oneLiner: "旧的细看" }; + const r = await M.triage({ items: [ + { bvid: A, verdict: "keep", summary: "讲透所有权", reason: "能跟着做", points: ["一", "二"], kept: true, note: "先看第 3 节", addTags: ["入门", "进阶"], folderId: "11" }, + { bvid: B, verdict: "unsure", summary: "说不清", removeTags: ["入门"], folderId: "11" } + ] }); + assert.deepStrictEqual(plain(r), { ok: true, data: { changed: 2 } }); + const a = local[K.triageAnalysis(A)]; + assert.deepStrictEqual([a.status, a.source, a.verdict, a.oneLiner, a.reason, a.points], ["done", "agent", "keep", "讲透所有权", "能跟着做", ["一", "二"]]); + assert.strictEqual(local[K.triageKept][A].action, "keep"); + assert.strictEqual(local[K.triageNotes][A].text, "先看第 3 节"); + const made = local[K.triageTags].find((t) => t.name === "进阶"); + assert.deepStrictEqual(plain([made.folder, local[K.triageVideoTags]]), ["11", { [A]: ["t1", made.id] }], "B, left with no tag, drops out"); + assert.strictEqual(log().length, 1); + assert.deepStrictEqual(plain(log()[0].made), [made.id]); + + const u = await undo(log()[0].id); + assert.deepStrictEqual(plain(u), { ok: true, data: { n: 2, skipped: 0 } }); + assert.strictEqual(local[K.triageAnalysis(A)], undefined, "a verdict it made is removed"); + assert.strictEqual(local[K.triageAnalysis(B)].oneLiner, "旧的细看", "one it replaced comes back"); + assert.deepStrictEqual(plain([local[K.triageKept], local[K.triageNotes], local[K.triageVideoTags]]), [{}, {}, { [B]: ["t1"] }]); + assert.deepStrictEqual(local[K.triageTags].map((t) => t.id), ["t1"], "the tag it made, carried by nothing now, goes"); + assert.deepStrictEqual(log(), []); + const again = await undo("gone"); + assert.strictEqual((await undo("gone", "https://www.bilibili.com/")).error, "网页不能发送 agent-undo"); + assert.deepStrictEqual([again.ok, again.error], [false, "这一步已经撤销过了"], "a step undone (or unknown) is refused"); +}); + +check("U leaves what the user changed since and says how many", async () => { + await M.triage({ items: [{ bvid: A, kept: true, note: "AI 的备注" }, { bvid: B, kept: true }] }); + const id = log()[0].id; + // The user then edits A's note and un-keeps B. + await ctx.MdgLimits.saveVideoNote(A, "我的备注"); + await ctx.MdgLimits.update(K.triageKept, (k) => (delete k[B], k)); + const u = await undo(id); + assert.deepStrictEqual(plain(u.data), { n: 2, skipped: 2 }); + assert.deepStrictEqual(plain([local[K.triageNotes][A].text, local[K.triageKept]]), ["我的备注", {}], "A's 保留 went back, the user's note and B stayed"); + assert.strictEqual((await undo(id)).ok, false, "the step is gone"); +}); + +check("a folder at its tag cap makes no new tag and says which; the journal keeps the last 20", async () => { + sync.triageTagLimit = 1; + const r = await M.triage({ items: [{ bvid: A, addTags: ["满了", "入门"], folderId: "11" }] }); + assert.deepStrictEqual(plain(r.data), { changed: 1, tagsNotMade: ["满了"] }); + assert.deepStrictEqual(local[K.triageVideoTags][A], ["t1"]); + for (let i = 0; i < 21; i++) await M.triage({ items: [{ bvid: A, note: `第 ${i} 次` }] }); + assert.strictEqual(log().length, ctx.MdgLimits.TRIAGE_UNDO_STEPS); + const nothing = await M.triage({ items: [{ bvid: A, note: "第 20 次" }] }); + assert.deepStrictEqual([nothing.data.changed, log().length], [0, 20], "an unchanged value is not journaled"); +}); + +// L3/L4: the worker and an open 分拣台 write the same keys. A page write from a copy that has not seen the tool's +// write yet, and writes that overlap, all land. +check("interleaved with a 分拣台 page: neither loses the other's tags or 保留", async () => { + const mem = { tags: plain(local[K.triageTags]), map: plain(local[K.triageVideoTags]) }; + const db = page.MdgTagStore.local({ keys: { tags: K.triageTags, map: K.triageVideoTags }, get: () => mem, put: (part) => Object.assign(mem, part), save: async (patch) => (await chrome.storage.local.set(patch), true) }); + const pageTag = (b) => db.write((cur) => ({ ...cur, map: { ...cur.map, [b]: ["t1"] } })); + // The tool's write lands; the page's copy does not have it when the page writes next. + await M.triage({ items: [{ bvid: A, addTags: ["进阶"], folderId: "11" }] }); + await pageTag(C); + const made = local[K.triageTags].find((t) => t.name === "进阶").id; + assert.deepStrictEqual(plain([local[K.triageVideoTags][A], local[K.triageVideoTags][C]]), [[made], ["t1"]], "the stale page write kept the tool's"); + // Overlapping, in both orders. + await Promise.all([pageTag("x1"), M.triage({ items: [{ bvid: B, addTags: ["新"], folderId: "11" }] }), pageTag("x2")]); + await Promise.all([M.triage({ items: [{ bvid: A, removeTags: ["进阶"], folderId: "11" }] }), pageTag("x3")]); + const map = local[K.triageVideoTags]; + assert.ok(["x1", "x2", "x3", C].every((k) => map[k]?.[0] === "t1") && map[B].length === 2 && map[A] === undefined, JSON.stringify(map)); + assert.ok(local[K.triageTags].some((t) => t.name === "新")); + // 保留: the page's storeEdit is a MdgLimits.update of the same key. + await Promise.all([M.triage({ items: [{ bvid: A, kept: true }] }), page.MdgLimits.update(K.triageKept, (k = {}) => ({ ...k, [C]: { action: "keep", at: 1 } }))]); + assert.deepStrictEqual(Object.keys(local[K.triageKept]).sort(), [A, C]); + // U of the tool's tags after the page tagged other videos leaves those. + await undo(log().find((e) => e.changes.tags?.[B]).id); + assert.ok(["x1", "x2", "x3", C].every((k) => map[k]?.[0] === "t1")); +}); + +check("propose queues a B站 change for the user, checked whole, never written to B站", async () => { + const ok = await M.propose({ action: "move", from: "11", to: "22", bvids: [A, B], reason: "都是入门" }); + assert.deepStrictEqual([ok.ok, ok.data.status], [true, "proposed"]); + assert.deepStrictEqual(plain(local[K.triageAgentProposals].map(({ id, at, ...p }) => p)), [{ action: "move", from: "11", bvids: [A, B], reason: "都是入门", to: "22" }]); + assert.ok((await M.propose({ action: "unfav", from: "toview", bvids: [C] })).ok, "稍后再看 when ticked"); + const before = plain(local); + for (const p of [ + { action: "delete", from: "11", bvids: [A] }, + { action: "unfav", from: "22", bvids: [A] }, // not ticked + { action: "unfav", from: "11", bvids: [C] }, // not in it + { action: "unfav", from: "11", bvids: [] }, + { action: "unfav", from: "11", bvids: [A, A] }, + { action: "unfav", from: "11", bvids: ["av1"] }, + { action: "unfav", from: "11", to: "22", bvids: [A] }, + { action: "move", from: "11", bvids: [A] }, + { action: "move", from: "11", to: "11", bvids: [A] }, + { action: "copy", from: "11", to: "toview", bvids: [A] }, + { action: "copy", from: "11", to: "33", bvids: [A] }, // not in the folder list + { action: "unfav", from: "11", bvids: [A], reason: "x".repeat(201) } + ]) { + const r = await M.propose(p); + assert.deepStrictEqual([r.ok, r.code], [false, "BAD_PARAMS"], JSON.stringify(p)); + } + assert.deepStrictEqual(local, before); + for (let i = 2; i < 20; i++) await M.propose({ action: "unfav", from: "11", bvids: [A] }); + const full = await M.propose({ action: "unfav", from: "11", bvids: [A] }); + assert.deepStrictEqual([full.code, local[K.triageAgentProposals].length], ["PROPOSALS_FULL", 20]); +}); + +(async () => { + for (const [name, fn] of cases) { + reset(); + await fn(); + console.log(`ok ${name}`); + } + console.log(`agent-writes selftest: all ${cases.length} passed`); +})().catch((e) => (console.error(e), process.exit(1))); diff --git a/extension/background.js b/extension/background.js index 96810afb..df458121 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1295,8 +1295,9 @@ async function probeAiChatCompletion({ baseUrl, apiKey, model }) { // background is the only writer to Obsidian. // follow-core.js (FollowCore) gives follow-bg.js the page's groupsOf, so an UP's groups are read one way on both sides. importScripts("obsidian.js", "triage/triage-bg.js", "triage/follow-core.js", "triage/follow-bg.js"); -// The agent interface's personal-data methods (docs/AGENT.md §2) read through triage-bg.js and follow-core.js. -importScripts("agent-mydata.js"); +// The agent interface's personal-data methods (docs/AGENT.md §2) read through triage-bg.js and follow-core.js; its +// 分拣台 writes go through triage-bg.js's helpers too. +importScripts("agent-mydata.js", "agent-writes.js"); // A video page's content script (content.js) sends these; the rest come from the extension's own pages. triageListen(null, BACKGROUND_HANDLERS, ["get-settings", "save-settings", "open-options", "player-ai-quick-action", "page-player", "fetch-json", "fetch-json-cancel"], (r) => r); diff --git a/extension/limits.js b/extension/limits.js index dbafd2d8..a8d089ad 100644 --- a/extension/limits.js +++ b/extension/limits.js @@ -58,6 +58,12 @@ triageNotes: "triage_notes", triageTags: "triage_tags", triageVideoTags: "triage_video_tags", + // B站's folder list as the 分拣台 last read it: { mid, folders: [{ id, title, count, … }] }. + triageFolderList: "triage_folder_list", + // An AI tool's changes over MCP (agent-writes.js): its local writes, newest last, each undoable from the 分拣台 (U), + // and its B站 proposals waiting for the user there. + triageAgentLog: "triage_agent_log", + triageAgentProposals: "triage_agent_proposals", triageBasket: "triage_basket", // 摘读记录's 星标 and tags: { tags: [{ id, name, color, rule }], items: { [record key]: { star, tags, ref, updatedAt } } }. // The record key is MdgNote.readingKey; ref keeps the title and address, so a record stays after its conversations go. @@ -155,12 +161,13 @@ // One 视频备注 saved by read-modify-write (the side panel, 摘读记录 and 分拣台): a fresh read, only this entry changed, // written back, so a note another page saved meanwhile is kept. Blank text deletes it; the stored text writes nothing. - // set writes the whole map (the 分拣台 passes its own, to know the echo). Resolves true when it wrote. - function saveVideoNote(id, text, set = (notes) => chrome.storage.local.set({ [KEYS.triageNotes]: notes })) { + // set writes the whole map (the 分拣台 passes its own, to know the echo). was: write only while the stored text is + // that ("" none). Resolves true when it wrote. + function saveVideoNote(id, text, set = (notes) => chrome.storage.local.set({ [KEYS.triageNotes]: notes }), was = null) { return locked(keyLock(KEYS.triageNotes), async () => { const notes = { ...(await chrome.storage.local.get(KEYS.triageNotes))[KEYS.triageNotes] }; const next = String(text ?? "").trim() ? String(text) : ""; - if ((notes[id]?.text || "") === next) return false; + if ((notes[id]?.text || "") === next || (was !== null && (notes[id]?.text || "") !== was)) return false; if (next) notes[id] = { text: next, updatedAt: Date.now() }; else delete notes[id]; await set(notes); diff --git a/extension/limits.selftest.js b/extension/limits.selftest.js index 4b9de8a8..2bd40785 100644 --- a/extension/limits.selftest.js +++ b/extension/limits.selftest.js @@ -80,7 +80,7 @@ const names = Object.values(L.KEYS).filter((v) => typeof v === "string"); // Renaming one loses the data stored under it. assert.deepStrictEqual([...names].sort(), [ "aiProviderKeys", "aiProviders", "follow_", "mdg_ai_conversations_v1", "mdg_ai_selected_provider", "mdg_obsidian_note_paths_v1", "mdg_player_ai_quick_action_v1", "mdg_reading_ai_history_v1", "mdg_reading_ai_proposal_v1", "mdg_reading_ai_recent_v1", "mdg_reading_ai_settings_v1", "mdg_reading_marks_v1", "mdg_subtitle_cache_", - "seen_", "triage_analysis_", "triage_basket", "triage_decisions_", "triage_folder_criteria", "triage_included_folders", "triage_kept", "triage_notes", "triage_removed", + "seen_", "triage_agent_log", "triage_agent_proposals", "triage_analysis_", "triage_basket", "triage_decisions_", "triage_folder_criteria", "triage_folder_list", "triage_included_folders", "triage_kept", "triage_notes", "triage_removed", "triage_seen_meta", "triage_snapshot_", "triage_tags", "triage_title_", "triage_video_tags" ]); assert.strictEqual(L.KEYS.triageDecisions(7), "triage_decisions_7"); diff --git a/extension/messages.js b/extension/messages.js index d8eff806..2ad98e17 100644 --- a/extension/messages.js +++ b/extension/messages.js @@ -63,6 +63,8 @@ // The MCP bridge (agent-bridge.js, in the background) to the video page (agent-page.js), docs/AGENT.md §2. Not here: // the page's moondigest-get / moondigest-frame, window.postMessage from agents' JS (docs/AGENT.md §1). "agent-call": { from: ["agent-bridge.js"], to: "agent-page.js", request: ["method", "params"], response: ["data"] }, + // U on an AI tool's 分拣台 writes (agent-writes.js's journal), from the 分拣台. + "agent-undo": { from: ["triage/fav-writes.js"], to: "agent-writes.js", request: ["id"], response: data }, "sidepanel-seek-video-time": { from: ["sidepanel.js"], to: "content.js", request: ["seconds", "videoId"], response: ["currentTime"] }, // The side panel's context, through the background diff --git a/extension/options.html b/extension/options.html index a57ed123..e852f374 100644 --- a/extension/options.html +++ b/extension/options.html @@ -354,6 +354,10 @@

高级

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看。只读,不改 B站 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

+
+

AI 工具还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 MCP 连接」。

+
+
导出设置

本页、收藏夹、关注和摘读记录的全部设置存成一个 JSON 文件,换电脑或换浏览器时导入。不含 Key 和分拣数据。

diff --git a/extension/options.js b/extension/options.js index 598c7023..5c3db819 100644 --- a/extension/options.js +++ b/extension/options.js @@ -689,9 +689,10 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. -// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +// 允许 AI 工具读我的收藏… and 允许 AI 工具改分拣台 work only over the MCP connection: greyed out while 允许 MCP 连接 is +// off, each keeping its own value. function syncMcpRows() { - MdgErrors.setReason(elements.mcpPersonalData, elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"); + for (const box of [elements.mcpPersonalData, elements.mcpWrites]) MdgErrors.setReason(box, elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"); } function syncObsidianBody() { diff --git a/extension/options.selftest.js b/extension/options.selftest.js index 8be3859c..75983dc3 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -520,7 +520,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("syncMcpRows")].join("\n"), page); await page.loadSettings(); const payload = page.readFormPayload(); - const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; + const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. @@ -528,6 +528,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, els.mcpPersonalData.title], [true, "先打开「允许 MCP 连接」"]); + assert.deepStrictEqual([els.mcpWrites.disabled, els.mcpWrites.title], [true, "先打开「允许 MCP 连接」"], "允许 AI 工具改分拣台 hangs on it too"); assert.deepStrictEqual([page.readFormPayload().mcpEnabled, page.readFormPayload().mcpPersonalData], [false, true], "turning MCP off keeps the stored value"); els.mcpEnabled.checked = true; page.syncMcpRows(); diff --git a/extension/settings.js b/extension/settings.js index 832bf854..c86427ca 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -84,6 +84,8 @@ mcpEnabled: false, // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. mcpPersonalData: false, + // The MCP server may also change the 分拣台's results and propose B站 favorites changes (agent-writes.js): off until 允许 AI 工具改分拣台. + mcpWrites: false, readerTheme: "light", readerFontScale: "m", readerLetterSpacing: "normal", diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index 09835fd2..12ebe609 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -27,7 +27,7 @@ const runName = (step, redo) => (redo ? `按新标准重新${STEP_NAME[step].sli const KEYS = MdgLimits.KEYS; const K = { lastFolder: "triage_last_folder", - folderList: "triage_folder_list", // { mid, folders }: B站's folder list as last read, so the page opens before it reads it again + folderList: KEYS.triageFolderList, // the page opens from it before it reads B站's list again tags: KEYS.triageTags, // [{ id, name, color, folder, rule? }]: folder is the mediaId the tag belongs to; rule is the one line the AI follows folderCriteria: KEYS.triageFolderCriteria, // { [mediaId]: 判断标准 } videoTags: KEYS.triageVideoTags, @@ -43,7 +43,9 @@ const K = { readAt: "triage_read_at", // { [mediaId]: ms }: when each folder's list was last read from B站, for 「今天 HH:MM 刷新过」 sort: "triage_sort", // { [mediaId]: { sort, dir } }: each folder's card order; none = 收藏时间 新→旧 aiRecent: "triage_ai_recent", // { [mediaId]: { at, bvids } }: 「AI 刚打的」, the videos the last applied 批量打 changed - aiProposals: "triage_ai_proposals" // { [mediaId]: proposal }: AI 打标签 proposals waiting for 应用, kept across a reload + aiProposals: "triage_ai_proposals", // { [mediaId]: proposal }: AI 打标签 proposals waiting for 应用, kept across a reload + agentLog: KEYS.triageAgentLog, + agentProposals: KEYS.triageAgentProposals }; const ALL = "all"; // the 所有收藏夹 view's folder-select value const REMOVED = "removed"; // the 已出分拣范围 view @@ -139,7 +141,9 @@ const S = { coarseFailed: new Map(), // bvid → why its 粗看 failed (this page only, like a failed 细看) analyzing: new Set(), seenRead: { busy: false, pages: 0, until: 0 }, // the background's read of the watch history (triage_seen_meta) - undo: [], // 收藏夹's steps; a folder switch clears them + undo: [], // 收藏夹's steps; a folder switch clears them but an AI tool's (agentSteps) + agentLog: [], // an AI tool's 分拣台 writes over MCP (agent-writes.js), each a U step + agentProposals: [], // its B站 proposals waiting for the user: [{ id, at, action, from, to?, bvids, reason? }] modeUndo: [], // 关注's steps ({ kind: "mode" }): U undoes only the steps of the mode it is pressed in lastSyncAt: 0, readAt: {}, // K.readAt @@ -172,13 +176,14 @@ const S = { const runs = MdgRuns.create({ redraw: () => renderStatus(), hold: (r) => THROTTLES[r.code] || null, sleep: (ms, ok) => sleepWhile(ms, ok) }); // What runs or waits, in this order on every tab (the first wins), then the finished lines. A run's row is its own; // these are mirrored from elsewhere. -const PILL = ["group", "stage1", "fav", "write", "load", "check", "sync", "ai", "proposal", "aiOther", "seen", "seenRead"]; +const PILL = ["group", "stage1", "fav", "write", "load", "check", "sync", "ai", "proposal", "aiOther", "agent", "seen", "seenRead"]; runs.put("load", () => loadAllActivity()); // S.syncing is the view run of the folder being read: one left meanwhile no longer shows it. const ownSync = () => Boolean(S.syncing?.alive); runs.put("sync", () => ownSync() && { text: `${UI.REFRESHING}…${pageText(S.mediaId)}` }); runs.put("proposal", () => S.ai.proposal && { text: UI.AI_TAGS_PENDING }); runs.put("aiOther", () => otherAiFolder() && { text: `「${folderName(otherAiFolder())}」的${UI.AI_TAGS_PENDING}` }); +runs.put("agent", () => S.agentProposals.length > 0 && { text: `AI 工具的 ${S.agentProposals.length} 条收藏夹提议等你确认` }); // 观看进度's 30-minute pause on B站 risk control comes from the background (triage_seen_meta); tick() redraws it. runs.put("seen", () => S.seenCfg.on && S.seenRead.until > Date.now() && { text: MdgErrors.LABEL.seen, wait: { until: S.seenRead.until, label: THROTTLES.THROTTLED.label } }); runs.put("seenRead", () => S.seenCfg.on && S.seenRead.busy && S.seenRead.pages && { text: `读观看进度… 已读 ${S.seenRead.pages} 页` }); @@ -192,7 +197,8 @@ const PILL_BTN = { fav: ["fav", MdgErrors.LABEL.stop, MdgErrors.LABEL.stopping, globalThis.TriageUi.WRITE_STOPPING], ai: ["tags", VIEW], proposal: ["tags", VIEW], - aiOther: ["aiOther", VIEW] + aiOther: ["aiOther", VIEW], + agent: ["agent", VIEW] }; const stopWhy = (act) => Object.values(PILL_BTN).find((b) => b[0] === act)?.[3] || ""; const aiRunning = () => Boolean(runs.get("ai")); diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index a0ddc66d..79c4acf9 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -226,7 +226,7 @@ async function openFolder(mediaId) { S.tagState = ""; S.aiRecentFilter = false; S.kindFilter = ""; - S.undo = []; + S.undo = agentSteps(); S.focused = ""; S.syncError = S.readError = ""; hideSyncNotice(); diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 8cfcb97d..16fcca4d 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -41,7 +41,7 @@ async function start() { bindEvents(); // Read up front: sidePanel.open must run inside the click's user gesture, before any await. chrome.tabs.getCurrent().then((tab) => (ownTabId = tab?.id)); - const [{ tags, videoTags, folderCriteria }, kept, basket, notes, settingsResp, aiHistory, aiRecent, sortBy, readAt, aiProposals] = await Promise.all([ + const [{ tags, videoTags, folderCriteria }, kept, basket, notes, settingsResp, aiHistory, aiRecent, sortBy, readAt, aiProposals, agentLog, agentProposals] = await Promise.all([ loadTagsAndCriteria(), storeGet(K.kept, {}), storeGet(K.basket, []), @@ -51,9 +51,12 @@ async function start() { storeGet(K.aiRecent, {}), storeGet(K.sort, {}), storeGet(K.readAt, {}), - storeGet(K.aiProposals, {}) + storeGet(K.aiProposals, {}), + storeGet(K.agentLog, []), + storeGet(K.agentProposals, []) ]); - Object.assign(S, { tags, videoTags, folderCriteria, kept, notes, aiHistory, aiRecent, sortBy, readAt }); + Object.assign(S, { tags, videoTags, folderCriteria, kept, notes, aiHistory, aiRecent, sortBy, readAt, agentProposals }); + followAgentLog(agentLog, false); S.ai.proposals = aiProposals; pruneAiProposals(); S.basket = basket.map(({ bvid, title, cover, upper, duration, opened }) => ({ bvid, title, cover, upper, duration, ...(opened ? { opened: true } : {}) })); @@ -124,7 +127,7 @@ function followIncluded(changes) { // Another triage tab wrote one of the lists every page writes whole: take its value, so the next // write here does not put back what it removed. This page's own writes come back as events too and are skipped: an // older one arriving after a newer edit would undo that edit. -const SHARED = { kept: {}, videoTags: {}, tags: [], basket: [], folderCriteria: {}, aiRecent: {} }; +const SHARED = { kept: {}, videoTags: {}, tags: [], basket: [], folderCriteria: {}, aiRecent: {}, agentProposals: [] }; function followShared(changes) { let changed = false; for (const [name, empty] of Object.entries(SHARED)) { @@ -138,6 +141,10 @@ function followShared(changes) { if (name === "tags") for (const id of [...S.tagFilter]) if (!tagById(id)) tagGone(id); changed = true; } + if (changes[K.agentLog]) { + followAgentLog(changes[K.agentLog].newValue || []); + changed = true; + } // 细看 results the background wrote (a 细看 here or in another tab, an AI tool's verdict) or 清理 removed. for (const [key, c] of Object.entries(changes)) { if (!key.startsWith(KEYS.triageAnalysisPrefix)) continue; @@ -394,6 +401,7 @@ function bindEvents() { const act = btn.dataset.head; if (act === "tags") openAi(); else if (act === "aiOther") openFolder(otherAiFolder()).then(openAi); + else if (act === "agent") reviewProposal(); else if (act === "stage1") { if (!runs.get("stage1")) { const list = stage1Pending(); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index b173afd7..dd018554 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -446,9 +446,12 @@ const verdictBadge = (b, v, low = v.low) => const KEEP_TIP = `保留标记${MdgErrors.TEXT.localOnly}`; const DONE_TIP = `已保留:${KEEP_TIP}。\n已取消收藏:已从 B站收藏夹移走,最近的操作可按 U 撤销。`; +// A verdict an AI tool wrote over MCP (agent-writes.js), on the card's meta line in place of the basis. +const AGENT_VERDICT = "AI 工具判断"; // What a 细看 judged from, on the card's meta line, in the video page's words: no subtitle (and why), or a subtitle // whose time range may not match the video. Nothing for a matching subtitle. function basisText(a) { + if (a.source === "agent") return AGENT_VERDICT; if (a.source !== "meta") return a.subtitleWarning ? esc(a.subtitleWarning.replace(/。$/, "")) : ""; return esc(`${MdgErrors.subtitleMissing({ subtitleNeedLogin: a.needLogin, subtitleFailure: a.subtitleFailure })},按简介和评论判断`); } diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 6591b7b8..73c9011b 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -297,12 +297,14 @@ const fav = { // The selection bar's and 「按 AI 判断」's batch 取消收藏. It runs to the end even after another folder opens; // 所有收藏夹 goes folder by folder. - async unfavMany(list) { + // picked: the folders, already confirmed (an AI tool's proposal), instead of asking. + async unfavMany(list, picked = null) { if (!list.length || W.busy()) return; const all = S.mediaId === ALL; - const folders = all - ? await pickBatchUnfavFolders(list) - : (await askConfirm(unfavManyTitle(list.length), UI.confirmList(list.map((it) => it.title)), unfavManyOk(list.length), { danger: true })) ? [String(S.mediaId)] : []; + const folders = picked + || (all + ? await pickBatchUnfavFolders(list) + : (await askConfirm(unfavManyTitle(list.length), UI.confirmList(list.map((it) => it.title)), unfavManyOk(list.length), { danger: true })) ? [String(S.mediaId)] : []); if (!folders.length) return; const mediaId = String(S.mediaId); const view = S.view.current; @@ -364,11 +366,12 @@ const fav = { }, // 移动 / 复制 (收藏到 from 已出分拣范围) of the selection. It runs to the end even after another folder opens. A new - // target folder is made only once this run has the slot. - async transfer(list) { + // target folder is made only once this run has the slot. picked: askTransfer's answer, already confirmed (an AI tool's + // proposal), instead of asking. + async transfer(list, picked = null) { if (!list.length || W.busy()) return; const from = String(S.mediaId); - const ask = await askTransfer(list); + const ask = picked || (await askTransfer(list)); if (!ask) return; const { how, target } = ask; const add = how === "add"; // from 已出分拣范围: no source folder on Bilibili @@ -474,6 +477,75 @@ const fav = { }; +// ---------- an AI tool's writes over MCP (agent-writes.js) ---------- +// Each journaled triage is a U step here: the background puts back what is still as the tool left it. A failed undo +// leaves the entry in the journal (or it is gone), and followAgentLog puts its step back from there. +function agentStep(e) { + return { + kind: "agent", + agentId: e.id, + ask: e.n > 1 ? [`撤销 AI 工具的修改?`, `

AI 工具改了 ${e.n} 个视频的 AI 判断、保留、备注或标签,撤销后改回去;你之后又改过的不动。

`, "撤销"] : null, + undo: async () => { + const r = await send({ type: "agent-undo", id: e.id }); + if (!r.ok) return toast(MdgErrors.text(r), true), ""; + const { n, skipped } = r.data; + return redraw() || `已撤销:AI 工具的修改(${n} 个视频${skipped ? `,${skipped} 个你之后改过,没动` : ""})`; + } + }; +} +// The steps of the journal, oldest first: a folder switch starts from them. +const agentSteps = () => S.agentLog.map(agentStep); +// The journal changed: entries it did not have go on top as steps, gone ones leave. Entries newer than any seen are +// the tool's new writes, said in a toast once the page is open (one put back after a failed undo is not). +let agentSeenAt = 0; +function followAgentLog(log, tell = true) { + const ids = new Set(log.map((e) => e.id)); + const known = new Set(S.agentLog.map((e) => e.id)); + S.undo = S.undo.filter((s) => s.kind !== "agent" || ids.has(s.agentId)); + for (const e of log.filter((e) => !known.has(e.id))) pushUndo(agentStep(e)); + S.agentLog = log; + const n = log.filter((e) => e.at > agentSeenAt).reduce((sum, e) => sum + e.n, 0); + agentSeenAt = Math.max(agentSeenAt, ...log.map((e) => e.at)); + if (tell && n) toast(`AI 工具改了 ${n} 个视频的分拣结果${UNDO}`); +} + +// Its B站 proposals, oldest first: 查看 opens the proposal's folder and asks. 执行 runs it as the page's own 取消收藏 / +// 移动 / 复制 would (B站's limits, U), 拒绝 drops it, Esc leaves it waiting. +const AGENT_VERB = { unfav: UNFAV, move: UI.transferVerb.move, copy: UI.transferVerb.copy }; +function dropProposal(id) { + const edit = (list) => list.filter((p) => p.id !== id); + S.agentProposals = edit(S.agentProposals); + return storeEdit(K.agentProposals, [], edit).then((list) => { + if (list) S.agentProposals = list; + renderStatus(); + }); +} +async function reviewProposal() { + const p = S.agentProposals[0]; + if (!p) return; + if (W.busy()) return toast(UI.busyText(W.busy()), true); + const ticked = S.folders.some((f) => String(f.id) === p.from); + if (ticked && String(S.mediaId) !== p.from) await openFolder(p.from); + const list = ticked ? p.bvids.map((b) => S.itemMap.get(b)).filter((it) => it?.aid && S.decisions[it.bvid]?.action !== "unfav") : []; + const verb = AGENT_VERB[p.action]; + const gone = p.bvids.length - list.length; + const why = !ticked ? `「${folderName(p.from)}」没有勾选分拣` : list.length ? "" : "这些视频已经不在这个收藏夹里了"; + const body = [ + p.reason && `

理由:${esc(p.reason)}

`, + UI.confirmList(list.map((it) => it.title)), + gone && list.length && `

另有 ${gone} 个已经不在「${esc(folderName(p.from))}」里,跳过。

`, + `

拒绝会删掉这条提议,B站不变。

` + ].filter(Boolean).join(""); + const title = `AI 工具提议:${verb} ${list.length || p.bvids.length} 个视频${p.to ? `到「${folderName(p.to)}」` : ""}`; + const ok = await askConfirm(title, body, `${verb} ${list.length} 个`, { danger: p.action !== "copy", reason: why, cancelText: "拒绝" }); + if (!ok && el.confirmDialog.returnValue !== "cancel") return; + if (ok && W.busy()) return toast(UI.busyText(W.busy()), true); + await dropProposal(p.id); + if (!ok) return toast("已拒绝 AI 工具的提议"); + if (p.action === "unfav") await fav.unfavMany(list, [p.from]); + else await fav.transfer(list, { how: p.action, target: { id: p.to } }); +} + // U undoes the last step of the mode it is pressed in. function undo() { return MdgTagStore.undoLast(followMode() ? S.modeUndo : S.undo, { confirm: (...a) => askConfirm(...a), say: (text, error) => toast(text, error) }); diff --git a/extension/triage/shared.js b/extension/triage/shared.js index 7181f1dc..6eb483d4 100644 --- a/extension/triage/shared.js +++ b/extension/triage/shared.js @@ -336,12 +336,16 @@ ``; // ----- 确认 (#confirmDialog): the 分拣台's and 摘读记录's questions, a red 删除 or a 撤销 instead of the browser's 确定 ----- // alone: only the one button (a notice, not a question). reason: the OK button is disabled with why (only 取消 is left). + // cancelText: the other button's words (拒绝); the dialog's returnValue then tells it ("cancel") from Esc (""). // Resolves true when OK was pressed. - function askConfirm(title, bodyHtml, okText, { danger = false, alone = false, reason = "" } = {}) { + function askConfirm(title, bodyHtml, okText, { danger = false, alone = false, reason = "", cancelText = "取消" } = {}) { const $ = (id) => document.getElementById(id); const dlg = $("confirmDialog"); const ok = $("confirmOk"); - dlg.querySelector("button[value=cancel]").hidden = alone; + const cancel = dlg.querySelector("button[value=cancel]"); + cancel.hidden = alone; + cancel.textContent = cancelText; + cancel.setAttribute("aria-label", cancelText); $("confirmTitle").textContent = title; $("confirmBody").innerHTML = bodyHtml; ok.className = danger ? "danger solid" : "primary"; diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index aaa28a0d..625487e4 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -4571,6 +4571,97 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = t.render = () => {}; delete store.triage_snapshot_D; } + + // An AI tool's writes over MCP (agent-writes.js). Its journal is U steps: a new entry goes on top with a toast, U asks + // the background to put it back, a failed U leaves the entry (it comes back as a step, no new toast), a folder switch + // keeps them, an entry gone from the journal leaves the stack. + { + openFake("A", [item(1)]); + t.S.agentLog = []; + const entry = (id, at, n = 1) => ({ id, at, n, changes: {}, made: [] }); + toasts.length = 0; + t.followAgentLog([entry("a1", 10)]); + assert.deepStrictEqual([t.S.undo.map((s) => s.agentId), toasts.at(-1)], [["a1"], `AI 工具改了 1 个视频的分拣结果${t.TriageUi.UNDO}`]); + handlers["agent-undo"] = (m) => ({ ok: true, data: { n: 1, skipped: m.id === "a1" ? 1 : 0 } }); + await t.undo(); + assert.deepStrictEqual([sent.at(-1).type, sent.at(-1).id, toasts.at(-1), t.S.undo.length], ["agent-undo", "a1", "已撤销:AI 工具的修改(1 个视频,1 个你之后改过,没动)", 0]); + t.followAgentLog([]); + t.followAgentLog([entry("a2", 20, 3)]); + t.S.undo.push({ kind: "keepMany", undo: async () => "" }); // a step of the user's own, after the tool's + handlers["agent-undo"] = () => ({ ok: false, error: "保存失败" }); + t.S.undo.pop(); + toasts.length = 0; + await t.undo(); + assert.deepStrictEqual(toasts, ["保存失败"], "U on several asks (askConfirm says yes here), then says the failure"); + // The background put the entry back: its step returns without a new toast. + t.followAgentLog([]); + t.followAgentLog([entry("a2", 20, 3)]); + assert.deepStrictEqual([t.S.undo.map((s) => s.agentId), toasts.length], [["a2"], 1]); + t.S.undo = vm.runInContext("agentSteps", ctx)(); + assert.deepStrictEqual(t.S.undo.map((s) => s.agentId), ["a2"], "a folder switch starts from the journal"); + t.followAgentLog([entry("a3", 30)]); + assert.deepStrictEqual(t.S.undo.map((s) => s.agentId), ["a3"], "an entry gone from the journal leaves the stack"); + t.S.agentLog = []; + t.S.undo = []; + delete handlers["agent-undo"]; + } + + // Its B站 proposals: 查看 asks about the oldest. 执行 runs the page's own 取消收藏 / 移动 (no second dialog) and drops it, + // 拒绝 drops it with no B站 request, Esc leaves it, a running B站 batch refuses it, and a folder no longer ticked + // offers only 拒绝. + { + const vids = [item(41), item(42), item(43)]; + t.S.allFolders = t.S.folders = [{ id: "P", title: "源" }, { id: "Q", title: "目标" }]; + t.S.included = ["P", "Q"]; + openFake("P", vids); + const real = t.askConfirm; + const asked = []; + const answer = (ok, value) => (t.askConfirm = async (title, body, okText, opts) => (asked.push([title, okText, opts]), (t.el.confirmDialog.returnValue = value), ok)); + const propose = (list) => { + store[t.K.agentProposals] = list; + t.S.agentProposals = structuredClone(list); + }; + const unfavs = []; + const moves = []; + handlers["triage-unfav"] = (m) => (unfavs.push(m), { ok: true }); + handlers["triage-transfer"] = (m) => (moves.push(m), { ok: true }); + propose([{ id: "p1", at: 1, action: "unfav", from: "P", bvids: ["BV41", "BV49"], reason: "重复" }, { id: "p2", at: 2, action: "move", from: "P", to: "Q", bvids: ["BV42", "BV43"] }]); + assert.strictEqual(t.activityState().text, "AI 工具的 2 条收藏夹提议等你确认"); + answer(false, ""); + await t.reviewProposal(); + assert.deepStrictEqual([store[t.K.agentProposals].length, unfavs.length], [2, 0], "Esc leaves it waiting"); + assert.deepStrictEqual(plain(asked.at(-1)), ["AI 工具提议:取消收藏 1 个视频", "取消收藏 1 个", { danger: true, reason: "", cancelText: "拒绝" }], "BV49 is not in the folder: skipped"); + answer(true, "ok"); + await t.reviewProposal(); + assert.deepStrictEqual(plain([unfavs.map((m) => [m.mediaId, m.aids]), store[t.K.agentProposals].map((p) => p.id), asked.length]), [[["P", [1041]]], ["p2"], 2], "执行: one B站 request, no second dialog"); + assert.strictEqual(t.S.decisions.BV41.action, "unfav"); + await t.reviewProposal(); + assert.deepStrictEqual(plain([moves.map((m) => [m.from, m.to, m.aids, m.move]), store[t.K.agentProposals], asked.at(-1)[0]]), [[["P", "Q", [1042, 1043], true]], [], "AI 工具提议:移动 2 个视频到「目标」"]); + // 拒绝, and a running batch refuses 查看 before asking. + propose([{ id: "p3", at: 3, action: "copy", from: "P", to: "Q", bvids: ["BV42"] }]); + let release = null; + handlers["triage-unfav"] = () => new Promise((r) => (release = () => r({ ok: true }))); + const running = t.fav.unfavMany([item(44)], ["P"]); + await until(() => release); + const n = asked.length; + await t.reviewProposal(); + assert.deepStrictEqual([asked.length, store[t.K.agentProposals].length, toasts.at(-1)], [n, 1, "批量取消收藏还在跑,等它完成"]); + release(); + await running; + answer(false, "cancel"); + const before = moves.length; + await t.reviewProposal(); + assert.deepStrictEqual([store[t.K.agentProposals], moves.length, toasts.at(-1)], [[], before, "已拒绝 AI 工具的提议"]); + // From a folder no longer ticked: nothing to run, only 拒绝. + propose([{ id: "p4", at: 4, action: "unfav", from: "Z", bvids: ["BV41"] }]); + answer(false, "cancel"); + await t.reviewProposal(); + assert.deepStrictEqual([asked.at(-1)[2].reason, store[t.K.agentProposals]], ["「Z」没有勾选分拣", []]); + t.askConfirm = real; + delete handlers["triage-unfav"]; + delete handlers["triage-transfer"]; + t.S.agentProposals = []; + } console.log("triage selftest: all passed"); })().catch((e) => { console.error(e); diff --git a/mcp/README.md b/mcp/README.md index 3e7ba27e..f16c8ca7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -26,6 +26,11 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 这三个工具一次只读一页,同一时间只跑一个,不会一口气读完。 +改分拣台的两个工具,默认关闭,要在「允许 MCP 连接」下面再打开「允许 AI 工具改分拣台」,关着时返回 `WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: + +- `set_triage({ items })`:一次最多 50 个视频,每个 `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`,只改给了的字段。`verdict`(`keep` / `drop` / `unsure`)要带 `summary`,和分拣台细看的结论一样显示,卡片上标「AI 工具判断」;`kept` 是保留标记;`note` 是备注,空字符串删掉;`addTags` / `removeTags` 是 `folderId` 这个收藏夹的标签名,视频要在这个勾选过的收藏夹里,没有的标签在名额内新建,满了不建并在结果里列出。只改本地,不改 B站。每次调用在分拣台里是一步,按 U 撤销;撤销时你之后又改过的不动。 +- `propose_fav_change({ action, from, to?, bvids, reason? })`:提议在 B站 取消收藏(`unfav`)、移动(`move`)或复制(`copy`)。它自己不改 B站:提议在分拣台的进度胶囊里等你,点「查看」打开 `from` 收藏夹列出这些视频,确认后照分拣台自己的操作执行(限流、按 U 撤销都一样),也可以拒绝。`from` 要是分拣台勾选过的收藏夹(或 `toview`),视频都在它本地那份里;`to` 是收藏夹列表里的另一个收藏夹,不能是稍后再看。最多 20 条提议同时等待,满了返回 `PROPOSALS_FULL`。 + `get_video` 默认返回紧凑文本,每条字幕一行 `[mm:ss] 内容`(超过一小时用 `hh:mm:ss`)。扩展原始 JSON 每条字幕都带 `from`、`to`、`content` 三个键名和小数时间,实测体积约是文本的两倍,而模型几乎用不到结束时间。确实要精确时间时传 `raw: true`。 ### 用 MCP 还是用 skill @@ -83,6 +88,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - `search_videos` 不开标签页:扩展后台带着你的登录状态直接请求 B站的搜索接口或 YouTube 的搜索结果页。一次只搜一个,两次搜索至少隔 2 秒。 - 开关默认关闭。关着的时候扩展不建任何连接。 - 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只发 B站 的读取请求(GET),不改 B站,也不改插件存的数据;API Key 和 AI 对话不会给出去。 +- 改分拣台也另有一个开关,默认关闭。打开后 AI 工具只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 ### 两个 MCP 客户端同时开着 @@ -105,6 +111,9 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 - `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 +- `WRITES_OFF`:「允许 AI 工具改分拣台」没开。 +- `BAD_PARAMS`:参数不对,错误里写着哪一项;这次什么都没写。 +- `PROPOSALS_FULL`:已经有 20 条提议在分拣台等你处理。 - `NOT_LOGGED_IN`:这个浏览器没登录 B站。 ### 测试 @@ -137,6 +146,11 @@ Three tools read your own Bilibili data. All are read-only and off by default: t These return one page per call and run one at a time, a second apart. +Two tools change the 分拣台. Both are off by default: turn on 允许 AI 工具改分拣台 under 允许 MCP 连接. While it is off they return `WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. + +- `set_triage({ items })`: up to 50 videos, each `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` and shows like a 细看's, marked AI 工具判断 on the card. `kept` is the 保留 mark. `note` is the note, `""` removes it. `addTags` / `removeTags` are tag names of folder `folderId`, which must be ticked and hold the video; a missing tag is made while the folder has room, else it is not made and the result lists it. Local only, nothing on Bilibili. Each call is one step in the 分拣台 that U undoes; what you changed since stays. +- `propose_fav_change({ action, from, to?, bvids, reason? })`: proposes unfav, move or copy on Bilibili. It changes nothing itself: the proposal waits in the 分拣台's progress pill; 查看 opens folder `from` with the videos listed, and confirming runs it as the 分拣台's own action (same rate limits, U undoes it), or you reject it. `from` must be a folder ticked in the 分拣台 (or `toview`) whose local copy has every video; `to` another folder of the folder list, not 稍后再看. At most 20 proposals wait at once; past that `PROPOSALS_FULL`. + `get_video` returns compact text by default, one transcript line per segment as `[mm:ss] text` (`hh:mm:ss` past an hour). The extension's raw JSON repeats the `from`, `to` and `content` keys and fractional times on every segment, which measures about twice the size, and models rarely need end times. Pass `raw: true` when you do need exact times. ### MCP or the skill @@ -194,6 +208,7 @@ Then turn on 允许 MCP 连接 in MoonDigest's settings (高级). - `search_videos` opens no tab: the extension's background requests Bilibili's search API or YouTube's results page directly, with your login. One search at a time, at least 2 s apart. - The switch is off by default. While off, the extension opens no socket. - Favorites, follows and history have their own switch, off by default and greyed out while 允许 MCP 连接 is off. When on, the extension only sends Bilibili read requests (GET): it changes nothing on Bilibili or in its own storage, and never gives out API keys or AI conversations. +- Changing the 分拣台 has its own switch too, off by default. When on, an AI tool changes only local 分拣 results directly, each call undoable in the 分拣台; a change on Bilibili it can only propose, and you confirm it in the 分拣台, not through the MCP client's permission prompt. ### Two MCP clients at once @@ -216,6 +231,9 @@ With no extension connected, a call returns `NOT_CONNECTED` at once, asking for - The subtitle says log in (`Subtitle note`): log in to Bilibili in that browser. - `AD_PLAYING`: a YouTube ad kept playing. Wait for it to end and grab the frame again. - `PERSONAL_DATA_OFF`: 允许 AI 工具读我的收藏、关注和历史 is off. +- `WRITES_OFF`: 允许 AI 工具改分拣台 is off. +- `BAD_PARAMS`: a parameter is wrong, the error says which; nothing was written. +- `PROPOSALS_FULL`: 20 proposals already wait for you in the 分拣台. - `NOT_LOGGED_IN`: that browser is not logged in to Bilibili. ### Tests diff --git a/mcp/server.js b/mcp/server.js index 5f8371c2..991936cc 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -204,6 +204,16 @@ export function formatFollows(d) { return lines.join("\n"); } +// The 分拣台 writes (docs/AGENT.md §2 triage / propose). +export function formatTriage(d) { + const notMade = d.tagsNotMade?.length ? ` Not made (the folder is at its tag limit): ${d.tagsNotMade.join(", ")}.` : ""; + return `Saved in the 分拣台: ${d.changed} video(s) changed. The user can undo this call there.${notMade}`; +} + +export function formatProposal(d) { + return `Proposed (${d.id}). Nothing has changed on Bilibili: the proposal waits in MoonDigest's 分拣台 until the user confirms or rejects it. Tell the user.`; +} + export function formatHistory(d) { const lines = [`History, newest first, ${d.items.length} items.${d.cursor ? ` More: cursor "${d.cursor}".` : " No more."}`]; for (const h of d.items) { @@ -293,13 +303,62 @@ const TOOLS = [ }, }, }, + { + name: "set_triage", + description: "Write the user's MoonDigest 分拣台 results for up to 50 Bilibili videos, as the 分拣台's own AI does: a verdict (keep / drop / unsure) with a one-line summary, 保留 (kept), a note, and tags of the folder. Judge a video only after reading it with get_video; take bvids and folder ids from get_favorites. Only the fields you give change, and the whole call is checked before anything is written. The user sees the results in the 分拣台 and can undo the call there. Nothing changes on Bilibili. Fails with WRITES_OFF until the user allows it.", + inputSchema: { + type: "object", + properties: { + items: { + type: "array", + minItems: 1, + maxItems: 50, + items: { + type: "object", + properties: { + bvid: { type: "string", pattern: "^BV[0-9A-Za-z]{10}$" }, + verdict: { type: "string", enum: ["keep", "drop", "unsure"], description: "Keep it, can go, or unsure. Needs summary." }, + summary: { type: "string", maxLength: 100, description: "One line: what the video is." }, + reason: { type: "string", maxLength: 300, description: "Why the verdict." }, + points: { type: "array", maxItems: 3, items: { type: "string", maxLength: 200 }, description: "Key points." }, + kept: { type: "boolean", description: "The 保留 mark: true marks it, false removes it." }, + note: { type: "string", maxLength: 2000, description: "The video's note; an empty string removes it." }, + folderId: { type: "string", description: "The folder whose tags addTags and removeTags name; the video must be in it." }, + addTags: { type: "array", maxItems: 10, items: { type: "string", maxLength: 12 }, description: "Tag names; a missing one is made while the folder has room." }, + removeTags: { type: "array", maxItems: 10, items: { type: "string", maxLength: 12 } }, + }, + required: ["bvid"], + additionalProperties: false, + }, + }, + }, + required: ["items"], + }, + }, + { + name: "propose_fav_change", + description: "Propose a change to the user's Bilibili favorites: unfav (取消收藏) videos from a folder, or move or copy them to another folder. It changes nothing by itself: the proposal waits in MoonDigest's 分拣台 until the user confirms it there (MoonDigest then makes the change) or rejects it; tell the user so. from must be a folder ticked in the 分拣台 (get_favorites reads it with source local) holding every video, to another folder from get_favorites. At most 20 proposals wait at a time. Fails with WRITES_OFF until the user allows it.", + inputSchema: { + type: "object", + properties: { + action: { type: "string", enum: ["unfav", "move", "copy"] }, + from: { type: "string", description: "The folder id the videos are in, or \"toview\"." }, + to: { type: "string", description: "The target folder id, for move and copy." }, + bvids: { type: "array", minItems: 1, maxItems: 50, items: { type: "string", pattern: "^BV[0-9A-Za-z]{10}$" } }, + reason: { type: "string", maxLength: 200, description: "One line for the user: why." }, + }, + required: ["action", "from", "bvids"], + }, + }, ]; -// The personal-data tools: socket method, the params it takes, the formatter. -const PERSONAL = { +// The tools answered by one socket method each: the method, the params it takes, the formatter. +const METHODS = { get_favorites: ["favorites", ["folderId", "page", "query"], formatFavorites], get_follows: ["follows", ["page", "query"], formatFollows], get_history: ["history", ["cursor"], formatHistory], + set_triage: ["triage", ["items"], formatTriage], + propose_fav_change: ["propose", ["action", "from", "to", "bvids", "reason"], formatProposal], }; // What MCP clients put in the model's prompt about this server: when to reach for it, not how each tool works. @@ -308,6 +367,7 @@ export const INSTRUCTIONS = [ "Use it whenever a video matters to the answer, not only when the user links one. When you research a topic, look for tutorials or compare sources and your search turns up Bilibili or YouTube videos, do not judge them by title, thumbnail or view count: read the most promising ones with get_video, check that they really cover what the user asked, and say what each one says, citing mm:ss timestamps and links that open at that moment. Drop the ones that do not match.", "Read a few candidates, one at a time, not dozens: Bilibili and YouTube rate-limit bursts. If a result says THROTTLED, stop and tell the user. To find videos on Bilibili or YouTube, search with search_videos, then read the best few with get_video.", "When the user asks about their own Bilibili (what they saved, who they follow, what they watched lately), use get_favorites, get_follows and get_history. They are read-only and off until the user allows them (PERSONAL_DATA_OFF: ask the user to turn it on). Read one page at a time and stop once you can answer.", + "When the user asks you to sort their favorites, set_triage writes MoonDigest's 分拣台 results (verdict, 保留, note, tags) and propose_fav_change proposes unfav, move or copy, which the user confirms in the 分拣台. Both are off until the user allows them (WRITES_OFF).", ].join("\n\n"); function errorResult(err) { @@ -331,7 +391,7 @@ export function createMcpServer(bridge) { const data = await bridge.call("search", { site, query: args.query.trim(), page }); return { content: [{ type: "text", text: args.raw ? JSON.stringify(data) : formatSearch(data) }] }; } - const mine = PERSONAL[params.name]; + const mine = METHODS[params.name]; if (mine) { const [method, keys, format] = mine; const data = await bridge.call(method, Object.fromEntries(keys.filter((k) => args[k] != null).map((k) => [k, args[k]]))); diff --git a/mcp/test/server.test.mjs b/mcp/test/server.test.mjs index ccff8192..8bcdc7d6 100644 --- a/mcp/test/server.test.mjs +++ b/mcp/test/server.test.mjs @@ -178,7 +178,7 @@ test("stdio binary lists the tools", async () => { const client = new Client({ name: "test", version: "0" }); await client.connect(transport); const { tools } = await client.listTools(); - assert.deepEqual(tools.map((t) => t.name), ["search_videos", "get_video", "get_frame", "get_favorites", "get_follows", "get_history"]); + assert.deepEqual(tools.map((t) => t.name), ["search_videos", "get_video", "get_frame", "get_favorites", "get_follows", "get_history", "set_triage", "propose_fav_change"]); // The server tells the model to check videos that turn up in its own searches, not only linked ones. assert.match(client.getInstructions() || "", /search turns up Bilibili or YouTube videos/); assert.match(tools.find((t) => t.name === "get_video").description, /search results/); @@ -296,3 +296,26 @@ test("personal data tools: params pass through, PERSONAL_DATA_OFF passes through ext.ws.close(); await s.close(); }); + +test("分拣台 writes: params pass through, the answers say what happened, WRITES_OFF passes through", async () => { + const s = await setup(); + let off = false; + const ext = fakeExtension(s.bridge.port, { onCall: (m) => (off + ? { ok: false, code: "WRITES_OFF", error: "Ask the user to turn it on." } + : { ok: true, data: m.method === "triage" ? { changed: 2, tagsNotMade: ["新"] } : { id: "p1", status: "proposed" } }) }); + await ext.opened; + await until(() => s.bridge.connected); + const items = [{ bvid: "BV1aaaaaaaaa", verdict: "keep", summary: "好", addTags: ["新"], folderId: "11" }]; + let r = await s.client.callTool({ name: "set_triage", arguments: { items } }); + assert.equal(r.content[0].text, "Saved in the 分拣台: 2 video(s) changed. The user can undo this call there. Not made (the folder is at its tag limit): 新."); + r = await s.client.callTool({ name: "propose_fav_change", arguments: { action: "move", from: "11", to: "22", bvids: ["BV1aaaaaaaaa"], extra: 1 } }); + assert.match(r.content[0].text, /^Proposed \(p1\)\. Nothing has changed on Bilibili/); + const calls = ext.messages.filter((m) => m.method).map(({ method, params }) => ({ method, params })); + assert.deepEqual(calls, [{ method: "triage", params: { items } }, { method: "propose", params: { action: "move", from: "11", to: "22", bvids: ["BV1aaaaaaaaa"] } }]); + off = true; + r = await s.client.callTool({ name: "set_triage", arguments: { items } }); + assert.equal(r.isError, true); + assert.equal(r.content[0].text, "WRITES_OFF: Ask the user to turn it on."); + ext.ws.close(); + await s.close(); +}); From d092801b65cb384412cdb57d2fb3fbe1d19b60b4 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:19:19 +0800 Subject: [PATCH 07/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=E5=8A=A0?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E7=BD=91=E9=A1=B5=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=20web=EF=BC=9A=E4=BB=BB=E6=84=8F=E7=BD=91=E9=A1=B5=E7=9A=84?= =?UTF-8?q?=E4=B8=BB=20
-

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看。只读,不改 B站 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

+

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

+
+

AI 工具还能通过 MCP 把视频加进或移出你的 YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,YouTube 限流就停。要先打开「允许 AI 工具读我的收藏、关注和历史」。

+
+
导出设置

本页、收藏夹、关注和摘读记录的全部设置存成一个 JSON 文件,换电脑或换浏览器时导入。不含 Key 和分拣数据。

diff --git a/extension/options.js b/extension/options.js index 598c7023..cafc412d 100644 --- a/extension/options.js +++ b/extension/options.js @@ -133,6 +133,7 @@ function init() { }); elements.obsidianEnabled.addEventListener("change", syncObsidianBody); elements.mcpEnabled.addEventListener("change", syncMcpRows); + elements.mcpPersonalData.addEventListener("change", syncMcpRows); document.addEventListener("click", (event) => { if (!(event.target instanceof Element) || !event.target.closest(".fixed-property-type-picker")) { closeAllFixedPropertyMenus(); @@ -690,8 +691,11 @@ function validateNoteExtras() { // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. // 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +// 允许 AI 工具改我的收藏 hangs on both the same way: a write reads first. function syncMcpRows() { - MdgErrors.setReason(elements.mcpPersonalData, elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"); + const mcp = elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"; + MdgErrors.setReason(elements.mcpPersonalData, mcp); + MdgErrors.setReason(elements.mcpCollectionWrite, mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」")); } function syncObsidianBody() { diff --git a/extension/options.selftest.js b/extension/options.selftest.js index 8be3859c..0b0aa41e 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -520,7 +520,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("syncMcpRows")].join("\n"), page); await page.loadSettings(); const payload = page.readFormPayload(); - const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; + const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "mcpCollectionWrite", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. @@ -536,6 +536,15 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, page.readFormPayload().mcpPersonalData], [true, false], "an off one stays off"); + // 允许 AI 工具改我的收藏 hangs on both: greyed out while either is off, with the first one to turn on as the reason. + assert.deepStrictEqual([els.mcpCollectionWrite.disabled, els.mcpCollectionWrite.title], [true, "先打开「允许 MCP 连接」"]); + els.mcpEnabled.checked = true; + page.syncMcpRows(); + assert.deepStrictEqual([els.mcpCollectionWrite.disabled, els.mcpCollectionWrite.title], [true, "先打开「允许 AI 工具读我的收藏、关注和历史」"]); + assert.strictEqual(page.readFormPayload().mcpCollectionWrite, true, "greyed out, it keeps its own value"); + els.mcpPersonalData.checked = true; + page.syncMcpRows(); + assert.deepStrictEqual([els.mcpCollectionWrite.disabled, page.readFormPayload().mcpCollectionWrite], [false, true]); })().catch((e) => (console.error(e), process.exit(1))); function plainOf(v) { return JSON.parse(JSON.stringify(v)); diff --git a/extension/settings.js b/extension/settings.js index 832bf854..2d2a31a5 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -84,6 +84,8 @@ mcpEnabled: false, // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. mcpPersonalData: false, + // It may also change them (agent-collections.js, docs/AGENT.md §2 collectionWrite): off until 允许 AI 工具改我的收藏. + mcpCollectionWrite: false, readerTheme: "light", readerFontScale: "m", readerLetterSpacing: "normal", diff --git a/mcp/README.md b/mcp/README.md index 3e7ba27e..cbed0057 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -26,6 +26,11 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 这三个工具一次只读一页,同一时间只跑一个,不会一口气读完。 +你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,AI 工具就是界面: + +- `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `youtube`,`kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改我的收藏」,关着时返回 `WRITE_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。 + `get_video` 默认返回紧凑文本,每条字幕一行 `[mm:ss] 内容`(超过一小时用 `hh:mm:ss`)。扩展原始 JSON 每条字幕都带 `from`、`to`、`content` 三个键名和小数时间,实测体积约是文本的两倍,而模型几乎用不到结束时间。确实要精确时间时传 `raw: true`。 ### 用 MCP 还是用 skill @@ -82,7 +87,8 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 扩展只会打开 B站视频页(`/video/`、`/bangumi/play/`、`/list/`、`/festival/`)和 YouTube 观看页(`/watch?v=`),其他网址一律返回 `BAD_URL`。扩展最多开一个自己的后台标签页,之后的视频都在它里面打开。`get_video` 不会切换标签页。Chrome 不给从没显示过的标签页加载视频,所以 `get_frame` 第一次在这种标签页里截帧时,会把它切到前台截完,再切回你原来的标签页,屏幕会闪一下。 - `search_videos` 不开标签页:扩展后台带着你的登录状态直接请求 B站的搜索接口或 YouTube 的搜索结果页。一次只搜一个,两次搜索至少隔 2 秒。 - 开关默认关闭。关着的时候扩展不建任何连接。 -- 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只发 B站 的读取请求(GET),不改 B站,也不改插件存的数据;API Key 和 AI 对话不会给出去。 +- 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只读,不改 B站 和 YouTube,也不改插件存的数据;API Key 和 AI 对话不会给出去。 +- 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 ### 两个 MCP 客户端同时开着 @@ -105,7 +111,8 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 - `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 -- `NOT_LOGGED_IN`:这个浏览器没登录 B站。 +- `WRITE_OFF`:「允许 AI 工具改我的收藏」没开。 +- `NOT_LOGGED_IN`:这个浏览器没登录 B站(收藏工具:没登录 YouTube)。 ### 测试 @@ -137,6 +144,11 @@ Three tools read your own Bilibili data. All are read-only and off by default: t These return one page per call and run one at a time, a second apart. +Two tools work on your own YouTube collections. MoonDigest has no pages for them: the AI tool is the UI. + +- `list_collection({ site, kind, id?, cursor?, raw? })`: `site` is `youtube`; `kind` is `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 AI 工具改我的收藏 under 允许 AI 工具读我的收藏、关注和历史; while it is off a write returns `WRITE_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. + `get_video` returns compact text by default, one transcript line per segment as `[mm:ss] text` (`hh:mm:ss` past an hour). The extension's raw JSON repeats the `from`, `to` and `content` keys and fractional times on every segment, which measures about twice the size, and models rarely need end times. Pass `raw: true` when you do need exact times. ### MCP or the skill @@ -193,7 +205,8 @@ Then turn on 允许 MCP 连接 in MoonDigest's settings (高级). - The extension opens only Bilibili video pages (`/video/`, `/bangumi/play/`, `/list/`, `/festival/`) and YouTube watch pages (`/watch?v=`). Any other URL gets `BAD_URL`. It keeps at most one background tab of its own and opens later videos in it. `get_video` never switches tabs. Chrome loads no video in a tab that has never been shown, so the first `get_frame` in such a tab brings it to the front for the frame and then switches back to the tab you had open: the screen flashes once. - `search_videos` opens no tab: the extension's background requests Bilibili's search API or YouTube's results page directly, with your login. One search at a time, at least 2 s apart. - The switch is off by default. While off, the extension opens no socket. -- Favorites, follows and history have their own switch, off by default and greyed out while 允许 MCP 连接 is off. When on, the extension only sends Bilibili read requests (GET): it changes nothing on Bilibili or in its own storage, and never gives out API keys or AI conversations. +- Favorites, follows and history have their own switch, off by default and greyed out while 允许 MCP 连接 is off. When on, the extension only reads: it changes nothing on Bilibili, YouTube or in its own storage, and never gives out API keys or AI conversations. +- Changing collections has one more switch, off by default and greyed out while either of the two above is off. YouTube reads and writes are sent from the extension's own background tab, in the YouTube page's own context, the same requests the YouTube page sends; your open tabs are not used. ### Two MCP clients at once @@ -216,7 +229,8 @@ With no extension connected, a call returns `NOT_CONNECTED` at once, asking for - The subtitle says log in (`Subtitle note`): log in to Bilibili in that browser. - `AD_PLAYING`: a YouTube ad kept playing. Wait for it to end and grab the frame again. - `PERSONAL_DATA_OFF`: 允许 AI 工具读我的收藏、关注和历史 is off. -- `NOT_LOGGED_IN`: that browser is not logged in to Bilibili. +- `WRITE_OFF`: 允许 AI 工具改我的收藏 is off. +- `NOT_LOGGED_IN`: that browser is not logged in to Bilibili (for the collection tools: YouTube). ### Tests diff --git a/mcp/server.js b/mcp/server.js index 3a453ead..afda767a 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -213,6 +213,33 @@ export function formatHistory(d) { return lines.join("\n"); } +// The user's collections on a site (docs/AGENT.md §2 collection / collectionWrite): one line per item. +export function formatCollection(d) { + const lines = [`${d.site} ${d.kind}${d.id ? ` ${d.id}` : ""}, ${d.items.length} items.${d.cursor ? ` More: cursor "${d.cursor}".` : " No more."}`]; + for (const v of d.items) { + if (v.videoId) lines.push("- " + joinParts([`${v.duration ? `[${clock(v.duration)}] ` : ""}${v.title}`, v.author, v.url, v.unavailable && "unavailable"])); + else if (v.channelId) lines.push("- " + joinParts([v.title, v.channelId, v.url])); + else lines.push("- " + joinParts([v.id, v.title, v.count != null && `${v.count} videos`])); + } + return lines.join("\n"); +} + +const STATUS = { unchanged: "already so, skipped", would_change: "would change", done: "done", unconfirmed: "sent, but the read after still shows the old state", failed: "failed" }; + +export function formatCollectionWrite(d) { + const lines = [`${d.dryRun ? "Dry run, nothing changed: " : ""}${d.op} on ${d.site} ${d.kind}${d.id ? ` ${d.id}` : ""}.`]; + for (const r of d.results) { + const state = r.before == null ? "" : ` (was ${r.before ? "in" : "not in"}${r.after == null ? "" : `, now ${r.after ? "in" : "not in"}`})`; + lines.push(`- ${r.item}: ${STATUS[r.status] || r.status}${state}${r.error ? `: ${r.error}` : ""}`); + } + if (!d.dryRun) lines.push(`Done: ${d.done.join(", ") || "none"}.`); + if (d.notDone.length) lines.push(`Not tried: ${d.notDone.join(", ")}.`); + if (d.stopped === "TIMEOUT") lines.push("Stopped: the call's time ran out. Call again with the items not tried."); + else if (d.stopped) lines.push(`Stopped: ${d.stopped}.${d.stopped === "THROTTLED" ? " Stop calling MoonDigest for now and tell the user; do not retry soon." : " Ask the user to log in to the site in that browser."}`); + if (d.undo) lines.push(`Undo: collection_write ${JSON.stringify(d.undo)}`); + return lines.join("\n"); +} + // --- MCP ---------------------------------------------------------------------- const URL_DOC = "A Bilibili video page (www.bilibili.com/video/…, /bangumi/play/…, /list/…, /festival/…) or a YouTube watch page (www.youtube.com/watch?v=…)."; @@ -293,6 +320,38 @@ const TOOLS = [ }, }, }, + { + name: "list_collection", + description: "Read-only: one page of the user's own collection on a site, through their browser and login. YouTube kinds: watch_later, playlists (the user's playlists: id, title), playlist (the videos of the playlist id), likes, subscriptions (channels), history. Bilibili favorites, follows and history stay in get_favorites, get_follows and get_history. Pass the returned cursor for the next page; read one page, then decide whether you need more. Fails with PERSONAL_DATA_OFF until the user allows it, UNSUPPORTED_KIND for a kind the site does not have.", + inputSchema: { + type: "object", + properties: { + site: { type: "string", description: "\"youtube\"." }, + kind: { type: "string", enum: ["watch_later", "playlists", "playlist", "likes", "subscriptions", "history"] }, + id: { type: "string", description: "The playlist id, for kind playlist." }, + cursor: { type: "string", description: "The cursor the previous call returned. Omit for the first page." }, + raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, + }, + required: ["site", "kind"], + }, + }, + { + name: "collection_write", + description: "Changes the user's own collection: adds videos to or removes them from YouTube watch_later or one of the user's own playlists (id). Likes and subscriptions are read-only. Unless the user named exactly these videos and this change, call with dryRun: true first and show the user what would change. At most 20 video ids a call; they are changed one at a time, a few seconds apart, each read before (already so: skipped) and after (confirmed). The result lists each item, what was done and not tried, and the undo call. If it says THROTTLED, stop calling MoonDigest and tell the user. Fails with WRITE_OFF until the user allows changes (dryRun needs only PERSONAL_DATA_OFF lifted).", + inputSchema: { + type: "object", + properties: { + site: { type: "string", description: "\"youtube\"." }, + op: { type: "string", enum: ["add", "remove"] }, + kind: { type: "string", enum: ["watch_later", "playlist"] }, + id: { type: "string", description: "The playlist id, for kind playlist (from list_collection kind playlists)." }, + items: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20, description: "Video ids (the v= of a watch URL)." }, + dryRun: { type: "boolean", description: "Only read: each item's current state and whether it would change." }, + raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, + }, + required: ["site", "op", "kind", "items"], + }, + }, ]; // The personal-data tools: socket method, the params it takes, the formatter. @@ -300,6 +359,7 @@ const PERSONAL = { get_favorites: ["favorites", ["folderId", "page", "query"], formatFavorites], get_follows: ["follows", ["page", "query"], formatFollows], get_history: ["history", ["cursor"], formatHistory], + list_collection: ["collection", ["site", "kind", "id", "cursor"], formatCollection], }; // What MCP clients put in the model's prompt about this server: when to reach for it, not how each tool works. @@ -308,6 +368,7 @@ export const INSTRUCTIONS = [ "Use it whenever a video matters to the answer, not only when the user links one. When you research a topic, look for tutorials or compare sources and your search turns up Bilibili or YouTube videos, do not judge them by title, thumbnail or view count: read the most promising ones with get_video, check that they really cover what the user asked, and say what each one says, citing mm:ss timestamps and links that open at that moment. Drop the ones that do not match.", "Read a few candidates, one at a time, not dozens: Bilibili and YouTube rate-limit bursts. If a result says THROTTLED, stop and tell the user. To find videos on Bilibili or YouTube, search with search_videos, then read the best few with get_video.", "When the user asks about their own Bilibili (what they saved, who they follow, what they watched lately), use get_favorites, get_follows and get_history. They are read-only and off until the user allows them (PERSONAL_DATA_OFF: ask the user to turn it on). Read one page at a time and stop once you can answer.", + "For the user's own YouTube (watch later, playlists, likes, subscriptions, history) use list_collection; to add videos to or remove them from watch later or a playlist, collection_write, off until the user allows changes (WRITE_OFF). Dry-run first unless the user named exactly the change, and keep the undo the result gives.", ].join("\n\n"); function errorResult(err) { @@ -338,6 +399,12 @@ export function createMcpServer(bridge) { const data = await bridge.call(method, Object.fromEntries(keys.filter((k) => args[k] != null).map((k) => [k, args[k]]))); return { content: [{ type: "text", text: args.raw ? JSON.stringify(data) : format(data) }] }; } + if (params.name === "collection_write") { + const data = await bridge.call("collectionWrite", Object.fromEntries(["site", "op", "kind", "id", "items", "dryRun"].filter((k) => args[k] != null).map((k) => [k, args[k]]))); + // A batch stopped by THROTTLED or a lost login is an error the agent must act on; the lists still come along. + const stopped = data.stopped && data.stopped !== "TIMEOUT"; + return { ...(stopped && { isError: true }), content: [{ type: "text", text: `${stopped ? `${data.stopped}: ` : ""}${args.raw ? JSON.stringify(data) : formatCollectionWrite(data)}` }] }; + } if (typeof args.url !== "string" || !args.url) throw new CodedError("BAD_URL", "url is required."); if (params.name === "get_video") { const data = await bridge.call("get", { url: args.url }); diff --git a/mcp/test/server.test.mjs b/mcp/test/server.test.mjs index b99c5639..7bcebbb1 100644 --- a/mcp/test/server.test.mjs +++ b/mcp/test/server.test.mjs @@ -7,7 +7,7 @@ import WebSocket from "ws"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { createBridge, createMcpServer, formatSearch, formatVideo, formatFavorites, formatFollows, formatHistory } from "../server.js"; +import { createBridge, createMcpServer, formatSearch, formatVideo, formatFavorites, formatFollows, formatHistory, formatCollection, formatCollectionWrite } from "../server.js"; const SERVER = fileURLToPath(new URL("../server.js", import.meta.url)); @@ -178,7 +178,7 @@ test("stdio binary lists the tools", async () => { const client = new Client({ name: "test", version: "0" }); await client.connect(transport); const { tools } = await client.listTools(); - assert.deepEqual(tools.map((t) => t.name), ["search_videos", "get_video", "get_frame", "get_favorites", "get_follows", "get_history"]); + assert.deepEqual(tools.map((t) => t.name), ["search_videos", "get_video", "get_frame", "get_favorites", "get_follows", "get_history", "list_collection", "collection_write"]); // The server tells the model to check videos that turn up in its own searches, not only linked ones. assert.match(client.getInstructions() || "", /search turns up Bilibili or YouTube videos/); assert.match(tools.find((t) => t.name === "get_video").description, /search results/); @@ -310,3 +310,50 @@ test("personal data tools: params pass through, PERSONAL_DATA_OFF passes through ext.ws.close(); await s.close(); }); + +test("collection formatting: videos, playlists, channels; a write's items, lists and undo", () => { + const t = formatCollection({ site: "youtube", kind: "watch_later", cursor: "TOK", items: [ + { videoId: "v1", url: "u1", title: "T", author: "A", duration: 125 }, { videoId: "v2", url: "u2", title: "Gone", author: "", duration: 0, unavailable: true }, + ] }); + assert.equal(t, 'youtube watch_later, 2 items. More: cursor "TOK".\n- [02:05] T · A · u1\n- Gone · u2 · unavailable'); + assert.equal(formatCollection({ site: "youtube", kind: "playlists", cursor: "", items: [{ id: "PL1", title: "Mine", count: 3 }, { id: "PL2", title: "New" }] }), "youtube playlists, 2 items. No more.\n- PL1 · Mine · 3 videos\n- PL2 · New"); + assert.equal(formatCollection({ site: "youtube", kind: "subscriptions", cursor: "", items: [{ channelId: "UC1", url: "c", title: "Chan" }] }), "youtube subscriptions, 1 items. No more.\n- Chan · UC1 · c"); + const w = formatCollectionWrite({ site: "youtube", op: "add", kind: "playlist", id: "PL1", dryRun: false, done: ["b"], notDone: ["d"], stopped: "THROTTLED", + results: [{ item: "a", status: "unchanged", before: true }, { item: "b", status: "done", before: false, after: true }, { item: "c", status: "failed", before: false, error: "HTTP 429" }], + undo: { site: "youtube", op: "remove", kind: "playlist", id: "PL1", items: ["b"] } }); + assert.equal(w, [ + "add on youtube playlist PL1.", "- a: already so, skipped (was in)", "- b: done (was not in, now in)", "- c: failed (was not in): HTTP 429", + "Done: b.", "Not tried: d.", "Stopped: THROTTLED. Stop calling MoonDigest for now and tell the user; do not retry soon.", + 'Undo: collection_write {"site":"youtube","op":"remove","kind":"playlist","id":"PL1","items":["b"]}', + ].join("\n")); + const dry = formatCollectionWrite({ site: "youtube", op: "remove", kind: "watch_later", dryRun: true, done: [], notDone: [], results: [{ item: "a", status: "would_change", before: true }], undo: null }); + assert.equal(dry, "Dry run, nothing changed: remove on youtube watch_later.\n- a: would change (was in)"); +}); + +test("collection tools: params pass through; a stopped batch is an error with its lists, a timed-out one is not; WRITE_OFF passes through", async () => { + const s = await setup(); + let reply = null; + const ext = fakeExtension(s.bridge.port, { onCall: () => reply }); + await ext.opened; + await until(() => s.bridge.connected); + reply = { ok: true, data: { site: "youtube", kind: "playlist", id: "PL1", items: [], cursor: "" } }; + let r = await s.client.callTool({ name: "list_collection", arguments: { site: "youtube", kind: "playlist", id: "PL1", cursor: "C", raw: false } }); + assert.equal(r.content[0].text, "youtube playlist PL1, 0 items. No more."); + const base = { site: "youtube", op: "add", kind: "watch_later", dryRun: false, results: [], done: [], notDone: ["v2"], undo: null }; + reply = { ok: true, data: { ...base, stopped: "THROTTLED" } }; + r = await s.client.callTool({ name: "collection_write", arguments: { site: "youtube", op: "add", kind: "watch_later", items: ["v1", "v2"], dryRun: false } }); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^THROTTLED: add on youtube watch_later\.[\s\S]*Not tried: v2\./); + reply = { ok: true, data: { ...base, stopped: "TIMEOUT" } }; + r = await s.client.callTool({ name: "collection_write", arguments: { site: "youtube", op: "add", kind: "watch_later", items: ["v1", "v2"] } }); + assert.equal(r.isError, undefined); + assert.match(r.content[0].text, /Call again with the items not tried\./); + reply = { ok: false, code: "WRITE_OFF", error: "Not allowed." }; + r = await s.client.callTool({ name: "collection_write", arguments: { site: "youtube", op: "remove", kind: "watch_later", items: ["v1"] } }); + assert.deepEqual([r.isError, r.content[0].text], [true, "WRITE_OFF: Not allowed."]); + const calls = ext.messages.filter((m) => m.method).map(({ method, params }) => ({ method, params })); + assert.deepEqual(calls[0], { method: "collection", params: { site: "youtube", kind: "playlist", id: "PL1", cursor: "C" } }); + assert.deepEqual(calls[1], { method: "collectionWrite", params: { site: "youtube", op: "add", kind: "watch_later", items: ["v1", "v2"], dryRun: false } }); + ext.ws.close(); + await s.close(); +}); From c1b2f295170497236fc357fd0a16c273c1d15e64 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:33:17 +0800 Subject: [PATCH 11/37] =?UTF-8?q?=E8=87=AA=E6=A3=80=EF=BC=9A=E5=88=86?= =?UTF-8?q?=E6=89=B9=E5=86=99=E5=85=A5=E7=9A=84=E9=97=B4=E9=9A=94=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E8=B4=9F=E8=BD=BD=E4=B8=8B=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=201=20=E7=A7=92=E7=9A=84=E8=B0=83=E7=94=A8=E9=97=B4=E9=9A=94?= =?UTF-8?q?=E5=BD=93=E6=88=90=E6=9D=A1=E7=9B=AE=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/agent-collections.selftest.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extension/agent-collections.selftest.js b/extension/agent-collections.selftest.js index 3432be4f..a8568f06 100644 --- a/extension/agent-collections.selftest.js +++ b/extension/agent-collections.selftest.js @@ -143,7 +143,8 @@ check("a write: no-op skipped, the rest written one at a time 1.5–3 s apart, c assert.deepStrictEqual(plain(r.data.undo), { site: "youtube", op: "remove", kind: "watch_later", items: [ID(2), ID(3)] }); assert.deepStrictEqual(sent("browse/edit_playlist").map(([, b]) => plain(b)), [ID(2), ID(3)].map((v) => ({ playlistId: "WL", actions: [{ action: "ACTION_ADD_VIDEO", addedVideoId: v }] })), "ID(1) was already there: not sent"); assert.deepStrictEqual(calls.map(([e]) => e), ["playlist/get_add_to_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist", "playlist/get_add_to_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist", "playlist/get_add_to_playlist"]); - const itemGaps = gaps.filter((g) => g !== 1000); + // A call gap is what is left of CALL_GAP_MS (at most 1000, less under load); an item gap is at least 1500. + const itemGaps = gaps.filter((g) => g > 1000); assert.strictEqual(itemGaps.length, 2, "a gap before each item after the first"); assert.ok(itemGaps.every((g) => g >= 1500 && g <= 3000), itemGaps.join()); // The undo runs as given and puts things back. From 498f01eec9d8040145e805f5c44caf2e34c337c4 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:36:04 +0800 Subject: [PATCH 12/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E7=9A=84=20read=20=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E5=86=99=E6=88=90=E7=AE=AD=E5=A4=B4=E5=87=BD=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E8=87=AA=E6=A3=80=E6=8C=89=20executeScript=20=E7=9A=84?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E7=BC=96=E8=AF=91=E6=AF=8F=E4=B8=AA=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E7=9A=84=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 2 +- extension/agent-sites/douyin.js | 1 - extension/agent-sites/registry.selftest.js | 9 +++++++++ extension/agent-sites/tiktok.js | 1 - extension/agent-sites/web.js | 2 +- extension/agent-sites/xiaoyuzhou.js | 3 +-- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 32c4af7c..737896da 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -140,7 +140,7 @@ MdgAgentSites.register({ - `key`: the registry prefixes the id, so two URLs of one video must give the same `itemId:part`. Two keys equal means the same video for tab reuse. - `read: "content"`: a content script (`agent-page.js`) answers in the page; Bilibili and YouTube only, it needs a manifest entry. -- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), `{ error, code? }` when the site refuses (the call's error, not asked again; `THROTTLED` for risk control), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. Write it as an arrow or `function`, not a method: Chrome runs `(` + its source + `)`. +- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), `{ error, code? }` when the site refuses (the call's error, not asked again; `THROTTLED` for risk control), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. Write it as an arrow (`read: async (url) => {…}`), never the method shorthand `read(url) {…}`: Chrome runs `(` + its source + `)`, which a method's source is not; `registry.selftest.js` compiles every site's `read` that way. - Host access: the manifest grants only Bilibili and YouTube; other sites are in `optional_host_permissions` (every http(s) site), so a new site needs no manifest change. Until the user allows its origins the site answers `NO_ACCESS`. Today the only way to allow them is the side panel's grant for reading web pages, which allows every site at once; a per-site 允许 in 设置 is not built yet. - `list`, `has`, `write`: agent-collections.js has checked the switches, the kind, the op and the items before; the adapter writes one item per `write` and never batches, retries or skips (the rules in §2 are agent-collections.js's). Throw an Error with code `THROTTLED` or `NOT_LOGGED_IN` to stop a batch. To run in the site's page, `MdgAgentBridge.inSitePage(site, url, ready, func, args, deadline)` runs `func(...args)` once in the MAIN world of the bridge's own tab on a page of `site` (loading `url` there when it shows another site), after `ready()` there returns `{ ok: true }`; both are serialized like `read`. `func` runs once even when it fails, so it should catch and return its errors. - A new site adds: `agent-sites/.js`, `agent-sites/.selftest.js` (`require("./registry.selftest.js").load(".js")` gives a registry with only that site loaded), one `importScripts("agent-sites/.js")` line in `background.js`, and its name in the MCP tools' descriptions (`mcp/server.js`). A `search` that reads `MdgErrors.CODE` also needs a line in `entry-matrix.selftest.js`'s `NOT_ENTRIES`. diff --git a/extension/agent-sites/douyin.js b/extension/agent-sites/douyin.js index 08f974c7..f232a5e4 100644 --- a/extension/agent-sites/douyin.js +++ b/extension/agent-sites/douyin.js @@ -11,7 +11,6 @@ MdgAgentSites.register({ const id = /^\/video\/(\d+)\/?$/.exec(u.pathname)?.[1] || u.searchParams.get("modal_id"); return /^\d+$/.test(id || "") ? `${id}:` : null; }, - // An arrow, not a method: executeScript serializes it as "(" + source + ")", which a method's source is not. read: async (url) => { const u = new URL(url); const id = /^\/video\/(\d+)/.exec(u.pathname)?.[1] || u.searchParams.get("modal_id"); diff --git a/extension/agent-sites/registry.selftest.js b/extension/agent-sites/registry.selftest.js index 7d53b70e..7d753500 100644 --- a/extension/agent-sites/registry.selftest.js +++ b/extension/agent-sites/registry.selftest.js @@ -37,5 +37,14 @@ if (require.main === module) { assert.strictEqual(S.keyOf("https://late.test/"), "late:x:"); assert.strictEqual(S.keyOf("https://other.test/abc"), "any:other.test:"); assert.throws(() => S.register({ ...any, id: "any2" }), /MdgAgentSites/); + + // Every site's function read compiles the way chrome.scripting.executeScript builds it: "(" + its source + ")". + // Method shorthand (read(url) {}) does not, and fails only in Chrome. + const { runtimes } = require("../page-sources.selftest.js"); + const worker = runtimes.find((r) => r.where === "background.js").files; + const all = load(...worker.filter((f) => f.startsWith("agent-sites/") && f !== "agent-sites/registry.js").map((f) => f.slice("agent-sites/".length))); + const reads = all.list().filter((s) => typeof s.read === "function"); + assert.ok(reads.length >= 4, `found ${reads.length} function reads`); + for (const s of reads) assert.doesNotThrow(() => new vm.Script(`(${s.read.toString()})`), `${s.id}'s read`); console.log("agent-sites registry selftest ok"); } diff --git a/extension/agent-sites/tiktok.js b/extension/agent-sites/tiktok.js index 26e452d1..79990ba3 100644 --- a/extension/agent-sites/tiktok.js +++ b/extension/agent-sites/tiktok.js @@ -7,7 +7,6 @@ MdgAgentSites.register({ const m = /^\/@[^/]*\/video\/(\d+)\/?$/.exec(u.pathname); return u.hostname === "www.tiktok.com" && m ? `${m[1]}:` : null; }, - // An arrow, not a method: executeScript serializes it as "(" + source + ")", which a method's source is not. read: async (url) => { const id = /\/video\/(\d+)/.exec(new URL(url).pathname)[1]; if (document.querySelector("#captcha_container, #captcha-verify-container-main-page, .captcha_verify_container")) { diff --git a/extension/agent-sites/web.js b/extension/agent-sites/web.js index 41a9bfe1..0cf653d1 100644 --- a/extension/agent-sites/web.js +++ b/extension/agent-sites/web.js @@ -6,7 +6,7 @@ MdgAgentSites.register({ fallback: true, key: (u) => `${u.href.split("#")[0]}:`, // Runs in the page's MAIN world: the page is untrusted, so every size is capped and every wait bounded. - read: async function read() { + read: async () => { if (document.readyState === "loading") return null; const MAX_TEXT = 100000; // characters of page text, and of all subtitle text together const MAX_TRACK_BYTES = 2000000; diff --git a/extension/agent-sites/xiaoyuzhou.js b/extension/agent-sites/xiaoyuzhou.js index a7e33b04..05e278d3 100644 --- a/extension/agent-sites/xiaoyuzhou.js +++ b/extension/agent-sites/xiaoyuzhou.js @@ -7,8 +7,7 @@ MdgAgentSites.register({ const m = /^\/episode\/([0-9A-Za-z]+)\/?$/.exec(u.pathname); return u.hostname.replace(/^www\./, "") === "xiaoyuzhoufm.com" && m ? `${m[1]}:` : null; }, - // A plain function, not a method: executeScript serializes it with toString(). - read: function (url) { + read: (url) => { const p = window.__NEXT_DATA__?.props?.pageProps; const e = p?.episode; // Another episode's data (a client-side navigation) counts as not ready. From 6297f17e5190d18b04cbef326386a6da2139b7df Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:37:24 +0800 Subject: [PATCH 13/37] =?UTF-8?q?MCP=20=E5=86=99=E5=88=86=E6=8B=A3?= =?UTF-8?q?=E5=8F=B0=EF=BC=9A=E4=BF=AE=E8=AF=84=E5=AE=A1=E6=89=BE=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E6=92=A4=E9=94=80=E3=80=81=E6=8F=90=E8=AE=AE=E5=92=8C?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=A0=A1=E9=AA=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤销 AI 工具写的判断:分拣台打开收藏夹时会给没有 criteria 的结论补上当前判断标准,原来撤销把这当成「用户之后改过」而跳过。比较时不看 criteria。 - 两个分拣台标签页同一条提议:先从队列里取走再执行,取走时发现已被别处拿走就提示「这条提议已在别的分拣台标签页处理」并停下,不再执行两次。 - 确认提议时恰好有 B站批量在跑:提议放回队列并提示,不再静默丢掉。 - verdict 用 Object.hasOwn 判断,toString、constructor、__proto__ 不再被当成判断(tag-core.js 的 verdictOf 也一样)。 - 撤销的消息没送到后台时,日志里还在的那一步放回 U 栈,后台放回日志的事件也不会再加一遍。 - set_triage 中途写失败:写失败的部分不进日志;错误里写明失败前已写入并可撤销的 bvid,免得 AI 工具盲目重试。 测试:每处各加一条,把修复改回去时对应测试失败。 --- docs/AGENT.md | 2 +- extension/agent-writes.js | 29 ++++++++++++++------ extension/agent-writes.selftest.js | 35 ++++++++++++++++++++++++ extension/tag-core.js | 4 +-- extension/tag-core.selftest.js | 5 ++++ extension/triage/fav-writes.js | 29 ++++++++++++++++---- extension/triage/triage.selftest.js | 41 +++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 16 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index a165d4f3..9e5b1c74 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -85,7 +85,7 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: `triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → 高级 → 允许 AI 工具改分拣台 (`mcpWrites`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `WRITES_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). -- `triage`: `items`, 1 to 50 of `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`, each bvid once; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` (≤ 100 chars); `reason` ≤ 300, `points` ≤ 3 of ≤ 200; they are stored as a 细看 result (`triage_analysis_`, `source: "agent"`). `kept` sets or clears the 保留 mark; `note` (≤ 2000, `""` removes it) the 备注; `addTags` / `removeTags` (≤ 10 names each) the tags of folder `folderId`, which must be ticked and whose local copy holds the video. A missing tag is made while the folder is under 收藏夹设置's tag cap; the others are answered in `tagsNotMade`. Each key is read-modify-written under the lock the 分拣台 takes for it (`MdgLimits.update`, `saveVideoNote`, `keyLock(triage_tags)` for the tag list and map), so neither side loses the other's write. The call is journaled (`triage_agent_log`, the last 20) with each value before and after; the 分拣台 shows each entry as a U step and its U (`agent-undo`) puts back each value still as the tool left it, leaving those the user changed since. `data`: `{ changed, tagsNotMade? }`. +- `triage`: `items`, 1 to 50 of `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`, each bvid once; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` (≤ 100 chars); `reason` ≤ 300, `points` ≤ 3 of ≤ 200; they are stored as a 细看 result (`triage_analysis_`, `source: "agent"`). `kept` sets or clears the 保留 mark; `note` (≤ 2000, `""` removes it) the 备注; `addTags` / `removeTags` (≤ 10 names each) the tags of folder `folderId`, which must be ticked and whose local copy holds the video. A missing tag is made while the folder is under 收藏夹设置's tag cap; the others are answered in `tagsNotMade`. Each key is read-modify-written under the lock the 分拣台 takes for it (`MdgLimits.update`, `saveVideoNote`, `keyLock(triage_tags)` for the tag list and map), so neither side loses the other's write. The call is journaled (`triage_agent_log`, the last 20) with each value before and after; the 分拣台 shows each entry as a U step and its U (`agent-undo`) puts back each value still as the tool left it, leaving those the user changed since (a result the 分拣台 stamped with its criteria on open counts as unchanged). A write failing midway keeps and journals what landed before it, and the error names those bvids. `data`: `{ changed, tagsNotMade? }`. - `propose`: `action` `unfav`, `move` or `copy`; `from` a folder ticked in the 分拣台 (or `toview`) whose local copy holds every one of `bvids` (1 to 50); `to`, for `move` and `copy` only, another folder of the list the 分拣台 last read (`triage_folder_list`), never `toview`; `reason` ≤ 200. Nothing is sent to Bilibili: the proposal is queued (`triage_agent_proposals`, at most 20, else `PROPOSALS_FULL`). The 分拣台's progress pill shows it; 查看 opens `from` and asks with the videos still there, 执行 runs the 分拣台's own 取消收藏 / 移动 / 复制 (its rate limits and undo), 拒绝 drops it, Esc leaves it. `data`: `{ id, status: "proposed" }`. Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answers `{ type: "pong" }` (WebSocket traffic keeps an MV3 service worker alive in Chrome 116+). diff --git a/extension/agent-writes.js b/extension/agent-writes.js index 89cdacc0..2ca41bd3 100644 --- a/extension/agent-writes.js +++ b/extension/agent-writes.js @@ -26,6 +26,9 @@ }; const given = (x, k) => x[k] !== undefined; const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + // The tool's verdict as written: the 分拣台 stamps a result without criteria with its folder's (loadResults) when it + // opens, which is not a change of the user's. + const asWritten = ({ criteria, ...a } = {}) => a; const tagNames = (v, name) => listOf(v ?? [], 10, name).map((n, j) => { const clean = MdgTagCore.cleanTagName(text(n, 40, `${name}[${j}]`)); @@ -39,7 +42,7 @@ if (!x || typeof x !== "object" || !BVID.test(x.bvid)) throw bad(`${at}.bvid must be a BV id (BV and 10 letters or digits).`); const it = { bvid: x.bvid }; if (given(x, "verdict")) { - if (!MdgTagCore.VERDICTS[x.verdict]) throw bad(`${at}.verdict must be keep, drop or unsure.`); + if (!Object.hasOwn(MdgTagCore.VERDICTS, x.verdict ?? "")) throw bad(`${at}.verdict must be keep, drop or unsure.`); const summary = text(x.summary ?? "", 100, `${at}.summary`); if (!summary) throw bad(`${at}.summary is required with a verdict.`); const points = listOf(x.points ?? [], TRIAGE_POINTS, `${at}.points`).map((p, j) => text(p, 200, `${at}.points[${j}]`)).filter(Boolean); @@ -80,21 +83,25 @@ const note = (part, b, was, next) => ((changes[part] ||= {})[b] = [was ?? null, next ?? null]); const made = []; const notMade = []; + const changedIds = () => [...new Set(Object.values(changes).flatMap(Object.keys))]; try { const keep = list.filter((it) => "kept" in it); if (keep.length) { + const kept = []; await MdgLimits.update(K.triageKept, (cur = {}) => { const next = { ...cur }; + kept.length = 0; for (const it of keep) { const was = cur[it.bvid] || null; const v = it.kept ? was || { action: "keep", at: now } : null; if (same(was, v)) continue; - note("kept", it.bvid, was, v); + kept.push([it.bvid, was, v]); if (v) next[it.bvid] = v; else delete next[it.bvid]; } - return changes.kept ? next : undefined; + return kept.length ? next : undefined; }); + for (const [b, was, v] of kept) note("kept", b, was, v); // once stored, as every part } for (const it of list.filter((x) => "note" in x)) { const was = (await chrome.storage.local.get(K.triageNotes))[K.triageNotes]?.[it.bvid]?.text || ""; @@ -102,7 +109,9 @@ } for (const it of list.filter((x) => x.analysis)) { const v = { ...it.analysis, analyzedAt: now }; - await MdgLimits.update(K.triageAnalysis(it.bvid), (was) => (note("analysis", it.bvid, was, v), v)); + let was; + await MdgLimits.update(K.triageAnalysis(it.bvid), (cur) => ((was = cur), v)); + note("analysis", it.bvid, was, v); // once stored: a failed write is not journaled } if (tagged.length) { const cap = TriageUi.numSetting("tagLimit", (await chrome.storage.sync.get("triageTagLimit")).triageTagLimit); @@ -140,15 +149,18 @@ for (const [b, was] of Object.entries(before)) note("tags", b, was, map[b] || []); }); } + } catch (e) { + // What landed stays (and is journaled below): the tool must not send it again blindly. + if (changedIds().length) e.message += ` Written before the failure, and kept (the user can undo it in the 分拣台): ${changedIds().join(", ")}.`; + throw e; } finally { - const n = new Set(Object.values(changes).flatMap(Object.keys)).size; + const n = changedIds().length; if (n || made.length) { const entry = { id: MdgTagCore.newTagId("a"), at: now, n, changes, made }; await MdgLimits.update(K.triageAgentLog, (log = []) => [...log, entry].slice(-MdgLimits.TRIAGE_UNDO_STEPS)); } } - const n = new Set(Object.values(changes).flatMap(Object.keys)).size; - return { changed: n, ...(notMade.length && { tagsNotMade: [...new Set(notMade)] }) }; + return { changed: changedIds().length, ...(notMade.length && { tagsNotMade: [...new Set(notMade)] }) }; } // U in the 分拣台 on one journaled triage: each value still as the tool left it goes back; one changed since stays @@ -180,7 +192,8 @@ for (const [b, [was, v]] of Object.entries(analysis)) { const key = K.triageAnalysis(b); await MdgLimits.locked(MdgLimits.keyLock(key), async () => { - if (!same((await chrome.storage.local.get(key))[key], v)) return skipped.add(b); + const cur = (await chrome.storage.local.get(key))[key]; + if (!cur || !same(asWritten(cur), asWritten(v))) return skipped.add(b); if (was) await chrome.storage.local.set({ [key]: was }); else await chrome.storage.local.remove(key); }); diff --git a/extension/agent-writes.selftest.js b/extension/agent-writes.selftest.js index bb53fddc..2f8a6632 100644 --- a/extension/agent-writes.selftest.js +++ b/extension/agent-writes.selftest.js @@ -97,6 +97,9 @@ check("triage refuses bad input whole, before writing anything", async () => { { items: [{ bvid: A, kept: "yes" }] }, { items: [{ bvid: A, verdict: "keep" }] }, { items: [{ bvid: A, verdict: "maybe", summary: "x" }] }, + { items: [{ bvid: A, verdict: "toString", summary: "x" }] }, + { items: [{ bvid: A, verdict: "constructor", summary: "x" }] }, + { items: [{ bvid: A, verdict: "__proto__", summary: "x" }] }, { items: [{ bvid: A, summary: "只有总结" }] }, { items: [{ bvid: A, verdict: "keep", summary: "x".repeat(101) }] }, { items: [{ bvid: A, note: 5 }] }, @@ -142,6 +145,38 @@ check("triage writes verdict, 保留, note and tags, journals it, and U puts all assert.deepStrictEqual([again.ok, again.error], [false, "这一步已经撤销过了"], "a step undone (or unknown) is refused"); }); +check("U puts back a verdict the 分拣台 stamped with its folder's criteria on open (not a change of the user's)", async () => { + await M.triage({ items: [{ bvid: A, verdict: "keep", summary: "好" }] }); + // loadResults (fav-folders.js) stamps a result without criteria under the key's lock. + await ctx.MdgLimits.update(K.triageAnalysis(A), (a) => ({ ...a, criteria: "" })); + assert.deepStrictEqual(plain((await undo(log()[0].id)).data), { n: 1, skipped: 0 }); + assert.strictEqual(local[K.triageAnalysis(A)], undefined); + // A 细看 run since is the user's: it stays. + await M.triage({ items: [{ bvid: A, verdict: "keep", summary: "好" }] }); + await ctx.MdgLimits.update(K.triageAnalysis(A), (a) => ({ ...a, oneLiner: "重新细看", criteria: "" })); + assert.deepStrictEqual(plain((await undo(log()[0].id)).data), { n: 1, skipped: 1 }); + assert.strictEqual(local[K.triageAnalysis(A)].oneLiner, "重新细看"); +}); + +check("a write failing midway says what was written before it, which stays journaled", async () => { + const set = chrome.storage.local.set; + chrome.storage.local.set = async (o) => { + if (K.triageAnalysis(B) in o) throw new Error("QUOTA_BYTES quota exceeded"); + return set(o); + }; + const r = await M.triage({ items: [{ bvid: A, kept: true }, { bvid: B, verdict: "drop", summary: "坏" }] }); + chrome.storage.local.set = set; + assert.strictEqual(r.ok, false); + assert.match(r.error, /^QUOTA_BYTES quota exceeded Written before the failure, and kept \(the user can undo it in the 分拣台\): BV1aaaaaaaaa\.$/); + assert.deepStrictEqual(plain(Object.keys(log()[0].changes.kept)), [A]); + // A write that failed is not journaled: the first one failing leaves nothing to undo or report. + delete local[K.triageAgentLog]; + chrome.storage.local.set = async (o) => { if (K.triageKept in o) throw new Error("QUOTA_BYTES quota exceeded"); return set(o); }; + const none = await M.triage({ items: [{ bvid: C, kept: true }] }); + chrome.storage.local.set = set; + assert.deepStrictEqual([none.error, log()], ["QUOTA_BYTES quota exceeded", []]); +}); + check("U leaves what the user changed since and says how many", async () => { await M.triage({ items: [{ bvid: A, kept: true, note: "AI 的备注" }, { bvid: B, kept: true }] }); const id = log()[0].id; diff --git a/extension/tag-core.js b/extension/tag-core.js index 754f38b3..4bd59925 100644 --- a/extension/tag-core.js +++ b/extension/tag-core.js @@ -58,8 +58,8 @@ // verdict: keep / drop / unsure, or "none"; stage: 2 细看, 1 粗看, 0 only invalid, -1 none. function verdictOf({ title, analysis, invalid } = {}) { let v = { verdict: "none", reason: "", stage: -1 }; - if (analysis?.status === "done") v = { verdict: VERDICTS[analysis.verdict] ? analysis.verdict : "unsure", reason: analysis.reason, stage: 2 }; - else if (VERDICTS[title?.verdict]) v = { verdict: title.verdict, reason: title.reason, stage: 1, low: title.confidence === "low" }; + if (analysis?.status === "done") v = { verdict: Object.hasOwn(VERDICTS, analysis.verdict) ? analysis.verdict : "unsure", reason: analysis.reason, stage: 2 }; + else if (Object.hasOwn(VERDICTS, title?.verdict ?? "")) v = { verdict: title.verdict, reason: title.reason, stage: 1, low: title.confidence === "low" }; if (!invalid) return v; return { verdict: "drop", reason: v.reason ? `${INVALID}。AI 原理由:${v.reason}` : INVALID, stage: Math.max(v.stage, 0) }; } diff --git a/extension/tag-core.selftest.js b/extension/tag-core.selftest.js index 22009358..01960d2d 100644 --- a/extension/tag-core.selftest.js +++ b/extension/tag-core.selftest.js @@ -24,6 +24,11 @@ assert.deepStrictEqual(V({}), { verdict: "none", reason: "", stage: -1 }); assert.deepStrictEqual(V({ title: { verdict: "drop", reason: "r", confidence: "low" } }), { verdict: "drop", reason: "r", stage: 1, low: true }); assert.deepStrictEqual(V({ title: { verdict: "t-must" } }), { verdict: "none", reason: "", stage: -1 }, "an old custom-tier 粗看 is not classified"); assert.deepStrictEqual(V({ title: { verdict: "keep" }, analysis: { status: "done", verdict: "t-must", reason: "a" } }), { verdict: "unsure", reason: "a", stage: 2 }, "细看 wins; unknown = 拿不准"); +// A name Object.prototype has is no verdict (stored data or an AI tool's words): never a function as the badge. +for (const bad of ["toString", "constructor", "__proto__"]) { + assert.deepStrictEqual(V({ title: { verdict: bad } }), { verdict: "none", reason: "", stage: -1 }, bad); + assert.strictEqual(V({ analysis: { status: "done", verdict: bad } }).verdict, "unsure", bad); +} assert.deepStrictEqual(V({ analysis: { status: "error" } }), { verdict: "none", reason: "", stage: -1 }); assert.deepStrictEqual(V({ invalid: true }), { verdict: "drop", reason: "视频已失效", stage: 0 }); assert.deepStrictEqual(V({ invalid: true, analysis: { status: "done", verdict: "keep", reason: "好" } }), { verdict: "drop", reason: "视频已失效。AI 原理由:好", stage: 2 }); diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 73c9011b..830c78cc 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -487,7 +487,13 @@ function agentStep(e) { ask: e.n > 1 ? [`撤销 AI 工具的修改?`, `

AI 工具改了 ${e.n} 个视频的 AI 判断、保留、备注或标签,撤销后改回去;你之后又改过的不动。

`, "撤销"] : null, undo: async () => { const r = await send({ type: "agent-undo", id: e.id }); - if (!r.ok) return toast(MdgErrors.text(r), true), ""; + if (!r.ok) { + toast(MdgErrors.text(r), true); + // No answer, or the background put the entry back: while it is in the journal, its step comes back. + const log = await storeGet(K.agentLog, []); + if (log.some((x) => x.id === e.id) && !S.undo.some((s) => s.agentId === e.id)) pushUndo(agentStep(e)); + return ""; + } const { n, skipped } = r.data; return redraw() || `已撤销:AI 工具的修改(${n} 个视频${skipped ? `,${skipped} 个你之后改过,没动` : ""})`; } @@ -502,7 +508,7 @@ function followAgentLog(log, tell = true) { const ids = new Set(log.map((e) => e.id)); const known = new Set(S.agentLog.map((e) => e.id)); S.undo = S.undo.filter((s) => s.kind !== "agent" || ids.has(s.agentId)); - for (const e of log.filter((e) => !known.has(e.id))) pushUndo(agentStep(e)); + for (const e of log.filter((e) => !known.has(e.id) && !S.undo.some((s) => s.agentId === e.id))) pushUndo(agentStep(e)); S.agentLog = log; const n = log.filter((e) => e.at > agentSeenAt).reduce((sum, e) => sum + e.n, 0); agentSeenAt = Math.max(agentSeenAt, ...log.map((e) => e.at)); @@ -512,8 +518,20 @@ function followAgentLog(log, tell = true) { // Its B站 proposals, oldest first: 查看 opens the proposal's folder and asks. 执行 runs it as the page's own 取消收藏 / // 移动 / 复制 would (B站's limits, U), 拒绝 drops it, Esc leaves it waiting. const AGENT_VERB = { unfav: UNFAV, move: UI.transferVerb.move, copy: UI.transferVerb.copy }; +// Resolves whether this call took it out of the stored queue: false when another tab already did (or the write failed). function dropProposal(id) { - const edit = (list) => list.filter((p) => p.id !== id); + let took = false; + const edit = (list) => list.filter((p) => p.id !== id || !(took = true)); + S.agentProposals = S.agentProposals.filter((p) => p.id !== id); + return storeEdit(K.agentProposals, [], edit).then((list) => { + if (list) S.agentProposals = list; + renderStatus(); + return took; + }); +} +// Back at the head of the queue, when it could not run after all. +function restoreProposal(p) { + const edit = (list) => (list.some((x) => x.id === p.id) ? list : [p, ...list]); S.agentProposals = edit(S.agentProposals); return storeEdit(K.agentProposals, [], edit).then((list) => { if (list) S.agentProposals = list; @@ -539,9 +557,10 @@ async function reviewProposal() { const title = `AI 工具提议:${verb} ${list.length || p.bvids.length} 个视频${p.to ? `到「${folderName(p.to)}」` : ""}`; const ok = await askConfirm(title, body, `${verb} ${list.length} 个`, { danger: p.action !== "copy", reason: why, cancelText: "拒绝" }); if (!ok && el.confirmDialog.returnValue !== "cancel") return; - if (ok && W.busy()) return toast(UI.busyText(W.busy()), true); - await dropProposal(p.id); + // Taken out of the queue first, so another 分拣台 tab cannot run it too; a batch that started meanwhile puts it back. + if (!(await dropProposal(p.id))) return toast("这条提议已在别的分拣台标签页处理", true); if (!ok) return toast("已拒绝 AI 工具的提议"); + if (W.busy()) return restoreProposal(p).then(() => toast(UI.busyText(W.busy()), true)); if (p.action === "unfav") await fav.unfavMany(list, [p.from]); else await fav.transfer(list, { how: p.action, target: { id: p.to } }); } diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 625487e4..d8553818 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -4657,10 +4657,51 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = answer(false, "cancel"); await t.reviewProposal(); assert.deepStrictEqual([asked.at(-1)[2].reason, store[t.K.agentProposals]], ["「Z」没有勾选分拣", []]); + // Two 分拣台 tabs, one proposal: the other tab took it out of the queue while this one asked, so this one runs nothing. + openFake("P", [item(45)]); + propose([{ id: "p5", at: 5, action: "unfav", from: "P", bvids: ["BV45"] }]); + store[t.K.agentProposals] = []; + answer(true, "ok"); + handlers["triage-unfav"] = () => ({ ok: true }); + const sentBefore = sent.length; + await t.reviewProposal(); + assert.deepStrictEqual([sent.slice(sentBefore).filter((m) => m.type === "triage-unfav").length, toasts.at(-1)], [0, "这条提议已在别的分拣台标签页处理"]); + // A batch that started while the dialog was open: the proposal goes back to the queue, nothing runs. + propose([{ id: "p6", at: 6, action: "move", from: "P", to: "Q", bvids: ["BV45"] }]); + let free = null; + handlers["triage-unfav"] = () => new Promise((r) => (free = () => r({ ok: true }))); + let batch = null; + t.askConfirm = async () => ((batch = t.fav.unfavMany([item(46)], ["P"])), (t.el.confirmDialog.returnValue = "ok"), true); + const movesBefore = moves.length; + await t.reviewProposal(); + assert.deepStrictEqual(plain([store[t.K.agentProposals].map((p) => p.id), t.S.agentProposals.map((p) => p.id), moves.length, toasts.at(-1)]), [["p6"], ["p6"], movesBefore, "批量取消收藏还在跑,等它完成"]); + await until(() => free); + free(); + await batch; t.askConfirm = real; delete handlers["triage-unfav"]; delete handlers["triage-transfer"]; t.S.agentProposals = []; + delete store[t.K.agentProposals]; + } + + // U on an AI tool's step whose message got no answer: the entry is still in the journal, so its step comes back, once. + { + openFake("A", [item(1)]); + t.S.agentLog = []; + const e = { id: "a9", at: 90, n: 1, changes: {}, made: [] }; + t.followAgentLog([e]); + store[t.K.agentLog] = [e]; + // The background took the entry (its removal reached the page) and put it back before answering with the failure. + handlers["agent-undo"] = () => (t.followAgentLog([]), { ok: false, error: "后台无响应" }); + await t.undo(); + assert.deepStrictEqual([t.S.undo.map((s) => s.agentId), toasts.at(-1)], [["a9"], "后台无响应"]); + t.followAgentLog([e]); // the put-back's own event, after the answer + assert.strictEqual(t.S.undo.length, 1, "the journal's event does not add it twice"); + t.S.agentLog = []; + t.S.undo = []; + delete store[t.K.agentLog]; + delete handlers["agent-undo"]; } console.log("triage selftest: all passed"); })().catch((e) => { From d9c84caa110adf403a473f41ef35e04384cc48f8 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:38:52 +0800 Subject: [PATCH 14/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=87=8C=E8=AF=BB=E7=9A=84=E7=AB=99=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E8=AF=84=E8=AE=BA=E4=B9=9F=E7=BB=8F=20MdgSites.cleanC?= =?UTF-8?q?omment=20=E6=B8=85=E7=90=86=EF=BC=88=E5=8C=BF=E5=90=8D=E3=80=81?= =?UTF-8?q?500=20=E5=AD=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/agent-bridge.js | 5 +++-- extension/agent-bridge.selftest.js | 10 ++++++---- extension/agent-sites/xiaoyuzhou.js | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 6fd2cc83..85079bc5 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -144,13 +144,14 @@ // A site whose adapter reads the page itself: read(url) runs in the page's own world (its globals in reach) and returns // §1's get data, { error, code? } when the site refuses (asked no more), or a falsy value while the page is not ready - // (callPage asks again). + // (callPage asks again). Its comments go through MdgSites.cleanComment here, like every other reader's. const readInPage = (site, url) => async (tabId) => { const [r] = await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: site.read, args: [url] }); const d = r?.result; if (!d) return undefined; if (typeof d.error === "string") return { ok: false, error: d.error, ...(typeof d.code === "string" ? { code: d.code } : {}) }; - return { ok: true, data: { ...d, site: site.id } }; + const comments = Array.isArray(d.comments) ? d.comments.map(MdgSites.cleanComment).filter((c) => c.message) : []; + return { ok: true, data: { ...d, comments, site: site.id } }; }; // A frame there: agent-page.js's frame, injected into the page's isolated world (it stays loaded for the next frame). diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 9bddd16a..1d48cc17 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -7,8 +7,8 @@ const path = process.getBuiltinModule("node:path"); const assert = process.getBuiltinModule("node:assert/strict"); const here = path.dirname(fs.realpathSync(process.argv[1])); -// The worker's order: the registry, the sites, the bridge. -const SOURCES = ["agent-sites/registry.js", "agent-sites/bilibili.js", "agent-sites/youtube.js", "agent-bridge.js"].map((f) => [f, fs.readFileSync(path.join(here, f), "utf8")]); +// The worker's order: sites.js (MdgSites.cleanComment) and what it needs, the registry, the sites, the bridge. +const SOURCES = ["common.js", "errors.js", "limits.js", "sites.js", "agent-sites/registry.js", "agent-sites/bilibili.js", "agent-sites/youtube.js", "agent-bridge.js"].map((f) => [f, fs.readFileSync(path.join(here, f), "utf8")]); const tick = () => new Promise((r) => setImmediate(r)); const settle = async () => { for (let i = 0; i < 20; i++) await tick(); }; @@ -444,12 +444,14 @@ check("a page-read site without access: NO_ACCESS for get and frame, no tab, not }); check("a page-read site runs read in its own tab's MAIN world, never the user's tab, until the page is ready", async () => { - const replies = [null, { title: "t", site: "spoofed" }]; + const raw = { uname: " ", like: "x", message: ` ${"字".repeat(600)} ` }; + const replies = [null, { title: "t", site: "spoofed", comments: [raw, { uname: "a", message: " " }] }]; const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], tabs: [{ id: 7, url: DEMO }], inject: () => replies.shift() }); demoSite(env); const s = await openSocket(env); const r = await call(env, s, "1", DEMO); - assert.deepEqual(r, { id: "1", ok: true, data: { title: "t", site: "demo" } }); + // Comments come out as MdgSites.cleanComment gives them (匿名, 500 characters); an empty one is dropped. + assert.deepEqual(r, { id: "1", ok: true, data: { title: "t", site: "demo", comments: [{ uname: "匿名", like: 0, message: "字".repeat(500) }] } }); assert.equal(env.created.length, 1); assert.equal(env.created[0].active, false); assert.deepEqual(env.injected.map((i) => i.tabId), [env.created[0].id, env.created[0].id]); diff --git a/extension/agent-sites/xiaoyuzhou.js b/extension/agent-sites/xiaoyuzhou.js index 05e278d3..6ee72853 100644 --- a/extension/agent-sites/xiaoyuzhou.js +++ b/extension/agent-sites/xiaoyuzhou.js @@ -27,7 +27,7 @@ MdgAgentSites.register({ segments: [], lang: "", warning: "", missing: "Xiaoyuzhou shows transcripts only to logged-in app users." + (audio ? ` Audio to transcribe: ${audio}` : "") }, - comments: (p.comments || []).map((c) => ({ uname: c.author?.nickname || "", like: c.likeCount || 0, message: c.text || "" })) + comments: (p.comments || []).map((c) => ({ uname: c.author?.nickname, like: c.likeCount, message: c.text })) }; } }); From 41a2bb87e007664c14915077d2b5bee9dc9f6f7a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:40:50 +0800 Subject: [PATCH 15/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=87=8C=E8=AF=BB=E5=9B=9E=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=B6=85=E8=BF=87=20100=20=E4=B8=87=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=EF=BC=88JSON=EF=BC=89=E6=8A=A5=20TOO=5FLARGE=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E8=BD=AC=E7=BB=99=20AI=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 4 ++-- extension/agent-bridge.js | 4 ++++ extension/agent-bridge.selftest.js | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 737896da..c4c3c5d1 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -105,7 +105,7 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `WRITE_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → 高级 → 允许 AI 工具改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TOO_LARGE` (a page-run `read` returned more than 1,000,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `WRITE_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → 高级 → 允许 AI 工具改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. @@ -140,7 +140,7 @@ MdgAgentSites.register({ - `key`: the registry prefixes the id, so two URLs of one video must give the same `itemId:part`. Two keys equal means the same video for tab reuse. - `read: "content"`: a content script (`agent-page.js`) answers in the page; Bilibili and YouTube only, it needs a manifest entry. -- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), `{ error, code? }` when the site refuses (the call's error, not asked again; `THROTTLED` for risk control), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. Write it as an arrow (`read: async (url) => {…}`), never the method shorthand `read(url) {…}`: Chrome runs `(` + its source + `)`, which a method's source is not; `registry.selftest.js` compiles every site's `read` that way. +- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), `{ error, code? }` when the site refuses (the call's error, not asked again; `THROTTLED` for risk control), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. An answer over 1,000,000 characters of JSON fails with `TOO_LARGE`; its `comments` go through `MdgSites.cleanComment` (500 characters, 匿名 for no name), so `read` passes the page's comments on as they are. Write it as an arrow (`read: async (url) => {…}`), never the method shorthand `read(url) {…}`: Chrome runs `(` + its source + `)`, which a method's source is not; `registry.selftest.js` compiles every site's `read` that way. - Host access: the manifest grants only Bilibili and YouTube; other sites are in `optional_host_permissions` (every http(s) site), so a new site needs no manifest change. Until the user allows its origins the site answers `NO_ACCESS`. Today the only way to allow them is the side panel's grant for reading web pages, which allows every site at once; a per-site 允许 in 设置 is not built yet. - `list`, `has`, `write`: agent-collections.js has checked the switches, the kind, the op and the items before; the adapter writes one item per `write` and never batches, retries or skips (the rules in §2 are agent-collections.js's). Throw an Error with code `THROTTLED` or `NOT_LOGGED_IN` to stop a batch. To run in the site's page, `MdgAgentBridge.inSitePage(site, url, ready, func, args, deadline)` runs `func(...args)` once in the MAIN world of the bridge's own tab on a page of `site` (loading `url` there when it shows another site), after `ready()` there returns `{ ok: true }`; both are serialized like `read`. `func` runs once even when it fails, so it should catch and return its errors. - A new site adds: `agent-sites/.js`, `agent-sites/.selftest.js` (`require("./registry.selftest.js").load(".js")` gives a registry with only that site loaded), one `importScripts("agent-sites/.js")` line in `background.js`, and its name in the MCP tools' descriptions (`mcp/server.js`). A `search` that reads `MdgErrors.CODE` also needs a line in `entry-matrix.selftest.js`'s `NOT_ENTRIES`. diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 85079bc5..0de1c969 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -11,6 +11,9 @@ const PAGE_WAIT_MS = 50_000; const CALL_BUDGET_MS = 55_000; // a call ends before the server's 60 s TIMEOUT, so the server gets its error const PAGE_RETRY_MS = 500; + // What a page-run read may return, as JSON characters: room for the web adapter's 100,000 characters of text and of + // subtitles with their timings. The page is untrusted, so anything larger is refused, not passed on to the agent. + const MAX_READ_CHARS = 1_000_000; // --- tabs -------------------------------------------------------------------- @@ -149,6 +152,7 @@ const [r] = await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: site.read, args: [url] }); const d = r?.result; if (!d) return undefined; + if (JSON.stringify(d).length > MAX_READ_CHARS) return { ok: false, code: "TOO_LARGE", error: `The page returned more than ${MAX_READ_CHARS} characters.` }; if (typeof d.error === "string") return { ok: false, error: d.error, ...(typeof d.code === "string" ? { code: d.code } : {}) }; const comments = Array.isArray(d.comments) ? d.comments.map(MdgSites.cleanComment).filter((c) => c.message) : []; return { ok: true, data: { ...d, comments, site: site.id } }; diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 1d48cc17..c3e9ed41 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -531,6 +531,16 @@ check("inSitePage: func failing is not run again (it may be a write); no access assert.deepEqual([denied.injected.length, denied.created.length], [0, 0]); }); +check("a page-read site's answer over 1,000,000 JSON characters is TOO_LARGE, asked once", async () => { + const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: () => ({ title: "x".repeat(1_000_000) }) }); + demoSite(env); + const s = await openSocket(env); + const r = await call(env, s, "1", DEMO); + assert.equal(r.code, "TOO_LARGE"); + assert.equal(r.ok, false); + assert.equal(env.injected.length, 1); +}); + (async () => { let failed = 0; for (const [name, fn] of cases) { From 47f37ebeb1c2ff02d26e992a8a81346b165ae193 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:45:17 +0800 Subject: [PATCH 16/37] =?UTF-8?q?agent=20=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E7=BD=91=E9=A1=B5=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E9=A1=B5=EF=BC=88HTTP=204xx/5xx=EF=BC=8C429=20=E4=B8=BA=20THRO?= =?UTF-8?q?TTLED=EF=BC=89=E5=92=8C=E5=B0=8F=E5=AE=87=E5=AE=99=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=9A=84=E5=8D=95=E9=9B=86=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20{=20error,=20code=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 2 +- extension/agent-sites/web.js | 4 ++++ extension/agent-sites/web.selftest.js | 6 +++++- extension/agent-sites/xiaoyuzhou.js | 2 ++ extension/agent-sites/xiaoyuzhou.selftest.js | 3 +++ extension/entry-matrix.selftest.js | 1 + 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index c4c3c5d1..27f87a0a 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -136,7 +136,7 @@ MdgAgentSites.register({ }); ``` -- `web` (`agent-sites/web.js`) is the fallback: any http(s) page no other site claims, keyed by its URL without the hash. Its `read` returns `kind: "video"` with the largest `
-

AI 工具还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 MCP 连接」。

+

AI 工具还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 AI 工具读我的收藏、关注和历史」。

diff --git a/extension/options.js b/extension/options.js index 20195914..0dcdc232 100644 --- a/extension/options.js +++ b/extension/options.js @@ -690,12 +690,13 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. -// 允许 AI 工具读我的收藏… and 允许 AI 工具改分拣台 work only over the MCP connection: greyed out while 允许 MCP 连接 is -// off, each keeping its own value. 允许 AI 工具改我的收藏 hangs on both the same way: a write reads first. +// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +// The two write switches (改分拣台, 改我的收藏) hang on both the same way: a write reads first. function syncMcpRows() { const mcp = elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"; - for (const box of [elements.mcpPersonalData, elements.mcpWrites]) MdgErrors.setReason(box, mcp); - MdgErrors.setReason(elements.mcpCollectionWrite, mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」")); + MdgErrors.setReason(elements.mcpPersonalData, mcp); + const read = mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」"); + for (const box of [elements.mcpWrites, elements.mcpCollectionWrite]) MdgErrors.setReason(box, read); } function syncObsidianBody() { diff --git a/extension/options.selftest.js b/extension/options.selftest.js index a1048e0c..998ff263 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -528,7 +528,6 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, els.mcpPersonalData.title], [true, "先打开「允许 MCP 连接」"]); - assert.deepStrictEqual([els.mcpWrites.disabled, els.mcpWrites.title], [true, "先打开「允许 MCP 连接」"], "允许 AI 工具改分拣台 hangs on it too"); assert.deepStrictEqual([page.readFormPayload().mcpEnabled, page.readFormPayload().mcpPersonalData], [false, true], "turning MCP off keeps the stored value"); els.mcpEnabled.checked = true; page.syncMcpRows(); @@ -537,15 +536,16 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, page.readFormPayload().mcpPersonalData], [true, false], "an off one stays off"); - // 允许 AI 工具改我的收藏 hangs on both: greyed out while either is off, with the first one to turn on as the reason. - assert.deepStrictEqual([els.mcpCollectionWrite.disabled, els.mcpCollectionWrite.title], [true, "先打开「允许 MCP 连接」"]); + // Both write switches (改分拣台, 改我的收藏) hang on both: greyed out while either is off, with the first one to turn on as the reason. + const writes = () => ["mcpWrites", "mcpCollectionWrite"].map((k) => [els[k].disabled, els[k].title]); + assert.deepStrictEqual(writes(), [[true, "先打开「允许 MCP 连接」"], [true, "先打开「允许 MCP 连接」"]]); els.mcpEnabled.checked = true; page.syncMcpRows(); - assert.deepStrictEqual([els.mcpCollectionWrite.disabled, els.mcpCollectionWrite.title], [true, "先打开「允许 AI 工具读我的收藏、关注和历史」"]); - assert.strictEqual(page.readFormPayload().mcpCollectionWrite, true, "greyed out, it keeps its own value"); + assert.deepStrictEqual(writes(), [[true, "先打开「允许 AI 工具读我的收藏、关注和历史」"], [true, "先打开「允许 AI 工具读我的收藏、关注和历史」"]]); + assert.deepStrictEqual([page.readFormPayload().mcpWrites, page.readFormPayload().mcpCollectionWrite], [true, true], "greyed out, they keep their own values"); els.mcpPersonalData.checked = true; page.syncMcpRows(); - assert.deepStrictEqual([els.mcpCollectionWrite.disabled, page.readFormPayload().mcpCollectionWrite], [false, true]); + assert.deepStrictEqual(writes().map(([d]) => d), [false, false]); })().catch((e) => (console.error(e), process.exit(1))); function plainOf(v) { return JSON.parse(JSON.stringify(v)); diff --git a/mcp/README.md b/mcp/README.md index b1e89c86..0e351918 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -29,9 +29,9 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,AI 工具就是界面: - `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `youtube`,`kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改我的收藏」,关着时返回 `WRITE_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。 +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。 -改分拣台的两个工具,默认关闭,要在「允许 MCP 连接」下面再打开「允许 AI 工具改分拣台」,关着时返回 `WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: +改分拣台的两个工具,默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改分拣台」,关着时返回 `TRIAGE_WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: - `set_triage({ items })`:一次最多 50 个视频,每个 `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`,只改给了的字段。`verdict`(`keep` / `drop` / `unsure`)要带 `summary`,和分拣台细看的结论一样显示,卡片上标「AI 工具判断」;`kept` 是保留标记;`note` 是备注,空字符串删掉;`addTags` / `removeTags` 是 `folderId` 这个收藏夹的标签名,视频要在这个勾选过的收藏夹里,没有的标签在名额内新建,满了不建并在结果里列出。只改本地,不改 B站。每次调用在分拣台里是一步,按 U 撤销;撤销时你之后又改过的不动。 - `propose_fav_change({ action, from, to?, bvids, reason? })`:提议在 B站 取消收藏(`unfav`)、移动(`move`)或复制(`copy`)。它自己不改 B站:提议在分拣台的进度胶囊里等你,点「查看」打开 `from` 收藏夹列出这些视频,确认后照分拣台自己的操作执行(限流、按 U 撤销都一样),也可以拒绝。`from` 要是分拣台勾选过的收藏夹(或 `toview`),视频都在它本地那份里;`to` 是收藏夹列表里的另一个收藏夹,不能是稍后再看。最多 20 条提议同时等待,满了返回 `PROPOSALS_FULL`。 @@ -94,7 +94,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 开关默认关闭。关着的时候扩展不建任何连接。 - 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只读,不改 B站 和 YouTube,也不改插件存的数据;API Key 和 AI 对话不会给出去。 - 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 -- 改分拣台也另有一个开关,默认关闭。打开后 AI 工具只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 +- 改分拣台也另有一个开关,默认关闭,和改收藏一样,上面两个有一个关着它就变灰。打开后 AI 工具只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 ### 两个 MCP 客户端同时开着 @@ -117,8 +117,8 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 - `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 -- `WRITE_OFF`:「允许 AI 工具改我的收藏」没开。 -- `WRITES_OFF`:「允许 AI 工具改分拣台」没开。 +- `COLLECTION_WRITES_OFF`:「允许 AI 工具改我的收藏」没开。 +- `TRIAGE_WRITES_OFF`:「允许 AI 工具改分拣台」没开。 - `BAD_PARAMS`:参数不对,错误里写着哪一项;这次什么都没写。 - `PROPOSALS_FULL`:已经有 20 条提议在分拣台等你处理。 - `NOT_LOGGED_IN`:这个浏览器没登录 B站(收藏工具:没登录 YouTube)。 @@ -156,9 +156,9 @@ These return one page per call and run one at a time, a second apart. Two tools work on your own YouTube collections. MoonDigest has no pages for them: the AI tool is the UI. - `list_collection({ site, kind, id?, cursor?, raw? })`: `site` is `youtube`; `kind` is `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 AI 工具改我的收藏 under 允许 AI 工具读我的收藏、关注和历史; while it is off a write returns `WRITE_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 AI 工具改我的收藏 under 允许 AI 工具读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. -Two tools change the 分拣台. Both are off by default: turn on 允许 AI 工具改分拣台 under 允许 MCP 连接. While it is off they return `WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. +Two tools change the 分拣台. Both are off by default: turn on 允许 AI 工具改分拣台 under 允许 AI 工具读我的收藏、关注和历史. While it is off they return `TRIAGE_WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. - `set_triage({ items })`: up to 50 videos, each `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` and shows like a 细看's, marked AI 工具判断 on the card. `kept` is the 保留 mark. `note` is the note, `""` removes it. `addTags` / `removeTags` are tag names of folder `folderId`, which must be ticked and hold the video; a missing tag is made while the folder has room, else it is not made and the result lists it. Local only, nothing on Bilibili. Each call is one step in the 分拣台 that U undoes; what you changed since stays. - `propose_fav_change({ action, from, to?, bvids, reason? })`: proposes unfav, move or copy on Bilibili. It changes nothing itself: the proposal waits in the 分拣台's progress pill; 查看 opens folder `from` with the videos listed, and confirming runs it as the 分拣台's own action (same rate limits, U undoes it), or you reject it. `from` must be a folder ticked in the 分拣台 (or `toview`) whose local copy has every video; `to` another folder of the folder list, not 稍后再看. At most 20 proposals wait at once; past that `PROPOSALS_FULL`. @@ -221,7 +221,7 @@ Then turn on 允许 MCP 连接 in MoonDigest's settings (高级). - The switch is off by default. While off, the extension opens no socket. - Favorites, follows and history have their own switch, off by default and greyed out while 允许 MCP 连接 is off. When on, the extension only reads: it changes nothing on Bilibili, YouTube or in its own storage, and never gives out API keys or AI conversations. - Changing collections has one more switch, off by default and greyed out while either of the two above is off. YouTube reads and writes are sent from the extension's own background tab, in the YouTube page's own context, the same requests the YouTube page sends; your open tabs are not used. -- Changing the 分拣台 has its own switch too, off by default. When on, an AI tool changes only local 分拣 results directly, each call undoable in the 分拣台; a change on Bilibili it can only propose, and you confirm it in the 分拣台, not through the MCP client's permission prompt. +- Changing the 分拣台 has its own switch too, off by default and, like changing collections, greyed out while either of the two above is off. When on, an AI tool changes only local 分拣 results directly, each call undoable in the 分拣台; a change on Bilibili it can only propose, and you confirm it in the 分拣台, not through the MCP client's permission prompt. ### Two MCP clients at once @@ -244,8 +244,8 @@ With no extension connected, a call returns `NOT_CONNECTED` at once, asking for - The subtitle says log in (`Subtitle note`): log in to Bilibili in that browser. - `AD_PLAYING`: a YouTube ad kept playing. Wait for it to end and grab the frame again. - `PERSONAL_DATA_OFF`: 允许 AI 工具读我的收藏、关注和历史 is off. -- `WRITE_OFF`: 允许 AI 工具改我的收藏 is off. -- `WRITES_OFF`: 允许 AI 工具改分拣台 is off. +- `COLLECTION_WRITES_OFF`: 允许 AI 工具改我的收藏 is off. +- `TRIAGE_WRITES_OFF`: 允许 AI 工具改分拣台 is off. - `BAD_PARAMS`: a parameter is wrong, the error says which; nothing was written. - `PROPOSALS_FULL`: 20 proposals already wait for you in the 分拣台. - `NOT_LOGGED_IN`: that browser is not logged in to Bilibili (for the collection tools: YouTube). diff --git a/mcp/server.js b/mcp/server.js index b7161d4d..14f77552 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -350,7 +350,7 @@ const TOOLS = [ }, { name: "collection_write", - description: "Changes the user's own collection: adds videos to or removes them from YouTube watch_later or one of the user's own playlists (id). Likes and subscriptions are read-only. Unless the user named exactly these videos and this change, call with dryRun: true first and show the user what would change. At most 20 video ids a call; they are changed one at a time, a few seconds apart, each read before (already so: skipped) and after (confirmed). The result lists each item, what was done and not tried, and the undo call. If it says THROTTLED, stop calling MoonDigest and tell the user. Fails with WRITE_OFF until the user allows changes (dryRun needs only PERSONAL_DATA_OFF lifted).", + description: "Changes the user's own collection: adds videos to or removes them from YouTube watch_later or one of the user's own playlists (id). Likes and subscriptions are read-only. Unless the user named exactly these videos and this change, call with dryRun: true first and show the user what would change. At most 20 video ids a call; they are changed one at a time, a few seconds apart, each read before (already so: skipped) and after (confirmed). The result lists each item, what was done and not tried, and the undo call. If it says THROTTLED, stop calling MoonDigest and tell the user. Fails with COLLECTION_WRITES_OFF until the user allows changes (dryRun needs only PERSONAL_DATA_OFF lifted).", inputSchema: { type: "object", properties: { @@ -367,7 +367,7 @@ const TOOLS = [ }, { name: "set_triage", - description: "Write the user's MoonDigest 分拣台 results for up to 50 Bilibili videos, as the 分拣台's own AI does: a verdict (keep / drop / unsure) with a one-line summary, 保留 (kept), a note, and tags of the folder. Judge a video only after reading it with get_video; take bvids and folder ids from get_favorites. Only the fields you give change, and the whole call is checked before anything is written. The user sees the results in the 分拣台 and can undo the call there. Nothing changes on Bilibili. Fails with WRITES_OFF until the user allows it.", + description: "Write the user's MoonDigest 分拣台 results for up to 50 Bilibili videos, as the 分拣台's own AI does: a verdict (keep / drop / unsure) with a one-line summary, 保留 (kept), a note, and tags of the folder. Judge a video only after reading it with get_video; take bvids and folder ids from get_favorites. Only the fields you give change, and the whole call is checked before anything is written. The user sees the results in the 分拣台 and can undo the call there. Nothing changes on Bilibili. Fails with PERSONAL_DATA_OFF or TRIAGE_WRITES_OFF until the user allows it.", inputSchema: { type: "object", properties: { @@ -399,7 +399,7 @@ const TOOLS = [ }, { name: "propose_fav_change", - description: "Propose a change to the user's Bilibili favorites: unfav (取消收藏) videos from a folder, or move or copy them to another folder. It changes nothing by itself: the proposal waits in MoonDigest's 分拣台 until the user confirms it there (MoonDigest then makes the change) or rejects it; tell the user so. from must be a folder ticked in the 分拣台 (get_favorites reads it with source local) holding every video, to another folder from get_favorites. At most 20 proposals wait at a time. Fails with WRITES_OFF until the user allows it.", + description: "Propose a change to the user's Bilibili favorites: unfav (取消收藏) videos from a folder, or move or copy them to another folder. It changes nothing by itself: the proposal waits in MoonDigest's 分拣台 until the user confirms it there (MoonDigest then makes the change) or rejects it; tell the user so. from must be a folder ticked in the 分拣台 (get_favorites reads it with source local) holding every video, to another folder from get_favorites. At most 20 proposals wait at a time. Fails with PERSONAL_DATA_OFF or TRIAGE_WRITES_OFF until the user allows it.", inputSchema: { type: "object", properties: { @@ -430,8 +430,8 @@ export const INSTRUCTIONS = [ "Use it whenever a video matters to the answer, not only when the user links one. When you research a topic, look for tutorials or compare sources and your search turns up Bilibili or YouTube videos, do not judge them by title, thumbnail or view count: read the most promising ones with get_video, check that they really cover what the user asked, and say what each one says, citing mm:ss timestamps and links that open at that moment. Drop the ones that do not match.", "Read a few candidates, one at a time, not dozens: Bilibili and YouTube rate-limit bursts. If a result says THROTTLED, stop and tell the user. To find videos on Bilibili or YouTube, search with search_videos, then read the best few with get_video.", "When the user asks about their own Bilibili (what they saved, who they follow, what they watched lately), use get_favorites, get_follows and get_history. They are read-only and off until the user allows them (PERSONAL_DATA_OFF: ask the user to turn it on). Read one page at a time and stop once you can answer.", - "For the user's own YouTube (watch later, playlists, likes, subscriptions, history) use list_collection; to add videos to or remove them from watch later or a playlist, collection_write, off until the user allows changes (WRITE_OFF). Dry-run first unless the user named exactly the change, and keep the undo the result gives.", - "When the user asks you to sort their favorites, set_triage writes MoonDigest's 分拣台 results (verdict, 保留, note, tags) and propose_fav_change proposes unfav, move or copy, which the user confirms in the 分拣台. Both are off until the user allows them (WRITES_OFF).", + "For the user's own YouTube (watch later, playlists, likes, subscriptions, history) use list_collection; to add videos to or remove them from watch later or a playlist, collection_write, off until the user allows changes (COLLECTION_WRITES_OFF). Dry-run first unless the user named exactly the change, and keep the undo the result gives.", + "When the user asks you to sort their favorites, set_triage writes MoonDigest's 分拣台 results (verdict, 保留, note, tags) and propose_fav_change proposes unfav, move or copy, which the user confirms in the 分拣台. Both are off until the user allows them (TRIAGE_WRITES_OFF).", ].join("\n\n"); function errorResult(err) { diff --git a/mcp/test/server.test.mjs b/mcp/test/server.test.mjs index 1ff76c26..8becd468 100644 --- a/mcp/test/server.test.mjs +++ b/mcp/test/server.test.mjs @@ -335,7 +335,7 @@ test("collection formatting: videos, playlists, channels; a write's items, lists assert.equal(dry, "Dry run, nothing changed: remove on youtube watch_later.\n- a: would change (was in)"); }); -test("collection tools: params pass through; a stopped batch is an error with its lists, a timed-out one is not; WRITE_OFF passes through", async () => { +test("collection tools: params pass through; a stopped batch is an error with its lists, a timed-out one is not; COLLECTION_WRITES_OFF passes through", async () => { const s = await setup(); let reply = null; const ext = fakeExtension(s.bridge.port, { onCall: () => reply }); @@ -353,9 +353,9 @@ test("collection tools: params pass through; a stopped batch is an error with it r = await s.client.callTool({ name: "collection_write", arguments: { site: "youtube", op: "add", kind: "watch_later", items: ["v1", "v2"] } }); assert.equal(r.isError, undefined); assert.match(r.content[0].text, /Call again with the items not tried\./); - reply = { ok: false, code: "WRITE_OFF", error: "Not allowed." }; + reply = { ok: false, code: "COLLECTION_WRITES_OFF", error: "Not allowed." }; r = await s.client.callTool({ name: "collection_write", arguments: { site: "youtube", op: "remove", kind: "watch_later", items: ["v1"] } }); - assert.deepEqual([r.isError, r.content[0].text], [true, "WRITE_OFF: Not allowed."]); + assert.deepEqual([r.isError, r.content[0].text], [true, "COLLECTION_WRITES_OFF: Not allowed."]); const calls = ext.messages.filter((m) => m.method).map(({ method, params }) => ({ method, params })); assert.deepEqual(calls[0], { method: "collection", params: { site: "youtube", kind: "playlist", id: "PL1", cursor: "C" } }); assert.deepEqual(calls[1], { method: "collectionWrite", params: { site: "youtube", op: "add", kind: "watch_later", items: ["v1", "v2"], dryRun: false } }); @@ -363,11 +363,11 @@ test("collection tools: params pass through; a stopped batch is an error with it await s.close(); }); -test("分拣台 writes: params pass through, the answers say what happened, WRITES_OFF passes through", async () => { +test("分拣台 writes: params pass through, the answers say what happened, TRIAGE_WRITES_OFF passes through", async () => { const s = await setup(); let off = false; const ext = fakeExtension(s.bridge.port, { onCall: (m) => (off - ? { ok: false, code: "WRITES_OFF", error: "Ask the user to turn it on." } + ? { ok: false, code: "TRIAGE_WRITES_OFF", error: "Ask the user to turn it on." } : { ok: true, data: m.method === "triage" ? { changed: 2, tagsNotMade: ["新"] } : { id: "p1", status: "proposed" } }) }); await ext.opened; await until(() => s.bridge.connected); @@ -381,7 +381,7 @@ test("分拣台 writes: params pass through, the answers say what happened, WRIT off = true; r = await s.client.callTool({ name: "set_triage", arguments: { items } }); assert.equal(r.isError, true); - assert.equal(r.content[0].text, "WRITES_OFF: Ask the user to turn it on."); + assert.equal(r.content[0].text, "TRIAGE_WRITES_OFF: Ask the user to turn it on."); ext.ws.close(); await s.close(); }); From cb7cdcc411f7f92913f4ed25bd488390cab5ed29 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:12:36 +0800 Subject: [PATCH 19/37] =?UTF-8?q?MCP=20=E8=AF=BB=E4=BB=BB=E6=84=8F?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=EF=BC=9A=E5=8D=95=E7=8B=AC=E7=9A=84=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E3=80=8C=E5=85=81=E8=AE=B8=20AI=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=AF=BB=E4=BB=BB=E6=84=8F=E7=BD=91=E9=A1=B5=E3=80=8D=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=85=B3=EF=BC=88WEB=5FPAGES=5FOFF=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E5=92=8C=E5=86=85=E7=BD=91=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E4=B8=80=E5=BE=8B=20BAD=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 6 +++--- docs/DESIGN.md | 2 +- extension/agent-bridge.js | 23 +++++++++++++++++++++-- extension/agent-bridge.selftest.js | 21 +++++++++++++++++++-- extension/agent-sites/web.js | 12 +++++++++++- extension/agent-sites/web.selftest.js | 17 ++++++++++++++++- extension/options.html | 4 ++++ extension/options.js | 5 +++-- extension/options.selftest.js | 3 ++- extension/settings.js | 2 ++ mcp/README.md | 8 ++++++-- mcp/server.js | 4 ++-- 12 files changed, 90 insertions(+), 17 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 8c3a7262..42a0f94a 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -75,7 +75,7 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: `get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. -A site whose adapter reads the page itself (§5, `read` a function) is read only in the extension's own tab: the user's tabs are never used. It fails with `NO_ACCESS` until the user has allowed the site's origins. Its `get` `data` is what `read` returned, with `site` set to the site's id. Its `frame` injects `agent-page.js` into the tab's isolated world and runs the same frame as above (`MdgAgentPage.frame`). +A site whose adapter reads the page itself (§5, `read` a function) is read only in the extension's own tab: the user's tabs are never used. It fails with `NO_ACCESS` until the user has allowed the site's origins. Any other web page (the `web` fallback) also needs 设置 → 高级 → 允许 AI 工具读任意网页 (`mcpWebPages`, under 允许 MCP 连接, off by default), else `WEB_PAGES_OFF`: a page the agent is steered to may be the user's mail or intranet, and an agent can pass on what it reads. The other sites do not hang on that switch. Its `get` `data` is what `read` returned, with `site` set to the site's id. Its `frame` injects `agent-page.js` into the tab's isolated world and runs the same frame as above (`MdgAgentPage.frame`). `search` opens no tab: the service worker fetches with the user's cookies. Bilibili: the WBI-signed `api.bilibili.com/x/web-interface/wbi/search/type?search_type=video` through the same channel as 分拣台 (`triageBiliGet`). YouTube: the `www.youtube.com/results?search_query=…` page, read from its `ytInitialData`; only the result list's own videos, no shorts, ads, channels, playlists or shelves. YouTube has page 1 only (about 20 videos); a `page` above 1 is an error. Searches run one at a time, at least 2 s apart; one still queued at the call's deadline is dropped with `TIMEOUT`. HTTP 412, B站's risk-control codes and YouTube's 429 are `THROTTLED`. No results is `items: []`, not an error. @@ -112,7 +112,7 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TOO_LARGE` (a page-run `read` returned more than 1,000,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 AI 工具改分拣台), `BAD_PARAMS` (a parameter of `triage` / `propose` is wrong; nothing was written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → 高级 → 允许 AI 工具改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `WEB_PAGES_OFF` (the user has not allowed reading any web page: 设置 → 高级 → 允许 AI 工具读任意网页), `TOO_LARGE` (a page-run `read` returned more than 1,000,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 AI 工具改分拣台), `BAD_PARAMS` (a parameter of `triage` / `propose` is wrong; nothing was written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → 高级 → 允许 AI 工具改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. @@ -143,7 +143,7 @@ MdgAgentSites.register({ }); ``` -- `web` (`agent-sites/web.js`) is the fallback: any http(s) page no other site claims, keyed by its URL without the hash. Its `read` returns `kind: "video"` with the largest `
+
+

AI 工具还能通过 MCP 读 B站、YouTube、TikTok、抖音、小宇宙以外的网页:网页里的视频和字幕,没有视频时读正文。只在扩展自己的后台标签页里打开,不读本机和内网地址。网页里的文字可能诱导 AI 工具把读到的内容发出去,用完请关掉。要先打开「允许 MCP 连接」。

+
+

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

diff --git a/extension/options.js b/extension/options.js index 0dcdc232..c1807256 100644 --- a/extension/options.js +++ b/extension/options.js @@ -690,11 +690,12 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. -// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +// 允许 AI 工具读任意网页 and 允许 AI 工具读我的收藏… work only over the MCP connection: greyed out while 允许 MCP 连接 is off, +// keeping their own values. // The two write switches (改分拣台, 改我的收藏) hang on both the same way: a write reads first. function syncMcpRows() { const mcp = elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"; - MdgErrors.setReason(elements.mcpPersonalData, mcp); + for (const box of [elements.mcpWebPages, elements.mcpPersonalData]) MdgErrors.setReason(box, mcp); const read = mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」"); for (const box of [elements.mcpWrites, elements.mcpCollectionWrite]) MdgErrors.setReason(box, read); } diff --git a/extension/options.selftest.js b/extension/options.selftest.js index 998ff263..6848a363 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -520,7 +520,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("syncMcpRows")].join("\n"), page); await page.loadSettings(); const payload = page.readFormPayload(); - const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "mcpCollectionWrite", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; + const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpWebPages", "mcpPersonalData", "mcpCollectionWrite", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. @@ -528,6 +528,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, els.mcpPersonalData.title], [true, "先打开「允许 MCP 连接」"]); + assert.deepStrictEqual([els.mcpWebPages.disabled, els.mcpWebPages.title], [true, "先打开「允许 MCP 连接」"], "允许 AI 工具读任意网页 hangs on it too"); assert.deepStrictEqual([page.readFormPayload().mcpEnabled, page.readFormPayload().mcpPersonalData], [false, true], "turning MCP off keeps the stored value"); els.mcpEnabled.checked = true; page.syncMcpRows(); diff --git a/extension/settings.js b/extension/settings.js index 3c534b81..a33eea40 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -84,6 +84,8 @@ mcpEnabled: false, // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. mcpPersonalData: false, + // The MCP server may also read any other web page (agent-sites/web.js, the fallback): off until 允许 AI 工具读任意网页. + mcpWebPages: false, // It may also change them (agent-collections.js, docs/AGENT.md §2 collectionWrite): off until 允许 AI 工具改我的收藏. mcpCollectionWrite: false, // The MCP server may also change the 分拣台's results and propose B站 favorites changes (agent-writes.js): off until 允许 AI 工具改分拣台. diff --git a/mcp/README.md b/mcp/README.md index 0e351918..fc8a09da 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -6,7 +6,7 @@ ### 这是什么 -一个可选的 MCP 服务器。装了它,Claude Code、Cursor、Codex 这类 MCP 客户端就能借你自己的浏览器搜索和读视频:B站、YouTube 的搜索结果;B站、YouTube、TikTok、抖音视频,小宇宙单集,以及任何带 `
-
-

AI 工具还能通过 MCP 读 B站、YouTube、TikTok、抖音、小宇宙以外的网页:网页里的视频和字幕,没有视频时读正文。只在扩展自己的后台标签页里打开,不读本机和内网地址。网页里的文字可能诱导 AI 工具把读到的内容发出去,用完请关掉。要先打开「允许 MCP 连接」。

-
-

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

diff --git a/extension/options.js b/extension/options.js index c1807256..0dcdc232 100644 --- a/extension/options.js +++ b/extension/options.js @@ -690,12 +690,11 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. -// 允许 AI 工具读任意网页 and 允许 AI 工具读我的收藏… work only over the MCP connection: greyed out while 允许 MCP 连接 is off, -// keeping their own values. +// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. // The two write switches (改分拣台, 改我的收藏) hang on both the same way: a write reads first. function syncMcpRows() { const mcp = elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"; - for (const box of [elements.mcpWebPages, elements.mcpPersonalData]) MdgErrors.setReason(box, mcp); + MdgErrors.setReason(elements.mcpPersonalData, mcp); const read = mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」"); for (const box of [elements.mcpWrites, elements.mcpCollectionWrite]) MdgErrors.setReason(box, read); } diff --git a/extension/options.selftest.js b/extension/options.selftest.js index 6848a363..998ff263 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -520,7 +520,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("syncMcpRows")].join("\n"), page); await page.loadSettings(); const payload = page.readFormPayload(); - const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpWebPages", "mcpPersonalData", "mcpCollectionWrite", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; + const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "mcpCollectionWrite", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. @@ -528,7 +528,6 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh els.mcpEnabled.checked = false; page.syncMcpRows(); assert.deepStrictEqual([els.mcpPersonalData.disabled, els.mcpPersonalData.title], [true, "先打开「允许 MCP 连接」"]); - assert.deepStrictEqual([els.mcpWebPages.disabled, els.mcpWebPages.title], [true, "先打开「允许 MCP 连接」"], "允许 AI 工具读任意网页 hangs on it too"); assert.deepStrictEqual([page.readFormPayload().mcpEnabled, page.readFormPayload().mcpPersonalData], [false, true], "turning MCP off keeps the stored value"); els.mcpEnabled.checked = true; page.syncMcpRows(); diff --git a/extension/settings.js b/extension/settings.js index a33eea40..3c534b81 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -84,8 +84,6 @@ mcpEnabled: false, // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. mcpPersonalData: false, - // The MCP server may also read any other web page (agent-sites/web.js, the fallback): off until 允许 AI 工具读任意网页. - mcpWebPages: false, // It may also change them (agent-collections.js, docs/AGENT.md §2 collectionWrite): off until 允许 AI 工具改我的收藏. mcpCollectionWrite: false, // The MCP server may also change the 分拣台's results and propose B站 favorites changes (agent-writes.js): off until 允许 AI 工具改分拣台. diff --git a/mcp/README.md b/mcp/README.md index 013dd985..12ad88e0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -6,7 +6,7 @@ ### 这是什么 -一个可选的 MCP 服务器。装了它,Claude Code、Cursor、Codex 这类 MCP 客户端就能借你自己的浏览器搜索和读视频:B站、YouTube 的搜索结果;B站、YouTube、TikTok、抖音视频,小宇宙单集,以及任何带 `
-
+
-

高级

+

MCP

-
-

排查问题时再开。

-
-

让 Cursor、Codex、Claude Code 等 AI 工具通过本机的 MCP 服务 搜索 B站和 YouTube 视频,读视频的字幕、评论和画面。用 MCP 时再打开:开着时插件每分钟试连一次本机 127.0.0.1:47823,连上后插件后台会一直运行。不用时关掉,能省一点电和内存。

@@ -362,6 +359,18 @@

高级

AI 工具还能通过 MCP 把视频加进或移出你的 YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,YouTube 限流就停。要先打开「允许 AI 工具读我的收藏、关注和历史」。

+
+
+ +
+
+

高级

+
+
+
+

排查问题时再开。

+
+
导出设置

本页、收藏夹、关注和摘读记录的全部设置存成一个 JSON 文件,换电脑或换浏览器时导入。不含 Key 和分拣数据。

diff --git a/extension/settings.js b/extension/settings.js index 3c534b81..e8976fb7 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -80,7 +80,7 @@ seenThreshold: SEEN_THRESHOLD.def, seenStyle: "badge", // one of SEEN_STYLES enableDebugLogs: false, - // The agent interface's MCP socket (agent-bridge.js in the background, docs/AGENT.md): off until 设置 → 高级 → 允许 MCP 连接. + // The agent interface's MCP socket (agent-bridge.js in the background, docs/AGENT.md): off until 设置 → MCP → 允许 MCP 连接. mcpEnabled: false, // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. mcpPersonalData: false, diff --git a/mcp/README.md b/mcp/README.md index 12ad88e0..a901a298 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -41,7 +41,7 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── ### 用 MCP 还是用 skill - **skill**(`skills/moondigest/`):agent 本来就在操控浏览器(能在页面里跑 JS)时用。不用开开关,不用装服务器。 -- **MCP**:任何 MCP 客户端都能用,agent 不需要会操控浏览器。代价是要装这个服务器,并在 MoonDigest 的「设置 → 高级 → 允许 MCP 连接」打开开关。 +- **MCP**:任何 MCP 客户端都能用,agent 不需要会操控浏览器。代价是要装这个服务器,并在 MoonDigest 的「设置 → MCP → 允许 MCP 连接」打开开关。 ### 安装 @@ -82,7 +82,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] 发布后把 `node` 和路径换成 `npx` 和 `["-y", "moondigest-mcp"]`。 -最后在 MoonDigest 的「设置 → 高级」打开「允许 MCP 连接」。 +最后在 MoonDigest 的「设置 → MCP」打开「允许 MCP 连接」。 ### 安全模型 @@ -168,7 +168,7 @@ Two tools change the 分拣台. Both are off by default: turn on 允许 AI 工 ### MCP or the skill - **Skill** (`skills/moondigest/`): use it when the agent already drives a browser and can run JS in the page. No switch, no server. -- **MCP**: works from any MCP client, and the agent needs no browser automation. It needs this server installed and MoonDigest's 设置 → 高级 → 允许 MCP 连接 (Settings → Advanced → Allow MCP connection) turned on. +- **MCP**: works from any MCP client, and the agent needs no browser automation. It needs this server installed and MoonDigest's 设置 → MCP → 允许 MCP 连接 (Settings → MCP → Allow MCP connection) turned on. ### Install @@ -209,7 +209,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] Once published, replace `node` and the path with `npx` and `["-y", "moondigest-mcp"]`. -Then turn on 允许 MCP 连接 in MoonDigest's settings (高级). +Then turn on 允许 MCP 连接 in MoonDigest's settings (MCP). ### Security model diff --git a/mcp/server.js b/mcp/server.js index 8345b1a8..27059355 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -92,7 +92,7 @@ export function createBridge({ port = process.env.MOONDIGEST_MCP_PORT ? Number(p await listen(); if (!wss) throw new CodedError("PORT_IN_USE", portError); if (!ext?.hello) { - throw new CodedError("NOT_CONNECTED", "No browser extension is connected. Ask the user to open the browser that has the MoonDigest extension and turn on 设置 → 高级 → 允许 MCP 连接 (Settings → Advanced → Allow MCP connection). If it is already on, the extension connects within a minute (it retries once a minute): try again then."); + throw new CodedError("NOT_CONNECTED", "No browser extension is connected. Ask the user to open the browser that has the MoonDigest extension and turn on 设置 → MCP → 允许 MCP 连接 (Settings → MCP → Allow MCP connection). If it is already on, the extension connects within a minute (it retries once a minute): try again then."); } const id = String(nextId++); const { ws } = ext; From 4972547c47b319dcf7dcdc14950c37f1a1abb182 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:54:40 +0800 Subject: [PATCH 31/37] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=92=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=9A=E3=80=8CAI=20=E5=B7=A5=E5=85=B7=E3=80=8D?= =?UTF-8?q?=E6=94=B9=E5=8F=AB=E3=80=8CAgent=E3=80=8D=EF=BC=88=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=90=8D=E3=80=81Agent=20=E5=88=A4=E6=96=AD=E3=80=81?= =?UTF-8?q?=E6=8F=90=E8=AE=AE=E5=92=8C=E6=92=A4=E9=94=80=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVACY.md | 2 +- README.md | 2 +- docs/AGENT.md | 10 ++++---- docs/ARCHITECTURE.md | 2 +- docs/DESIGN.md | 2 +- docs/TRIAGE.md | 6 ++--- extension/agent-bridge.js | 6 ++--- extension/agent-collections.selftest.js | 2 +- extension/agent-mydata.js | 2 +- extension/agent-mydata.selftest.js | 2 +- extension/agent-writes.js | 2 +- extension/agent-writes.selftest.js | 2 +- extension/options.css | 2 +- extension/options.html | 8 +++---- extension/options.js | 4 ++-- extension/options.selftest.js | 4 ++-- extension/settings.js | 6 ++--- extension/triage/fav-base.js | 2 +- extension/triage/fav-render.js | 2 +- extension/triage/fav-writes.js | 10 ++++---- extension/triage/triage.selftest.js | 12 +++++----- mcp/README.md | 32 ++++++++++++------------- 22 files changed, 61 insertions(+), 61 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index d19afe7e..d9a094aa 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,7 @@ MoonDigest 的隐私政策。适用于 Chrome 应用商店版和 GitHub 发布 - 设置、分拣结果、备注和对话都存在浏览器本地。API Key 也存在本地,不会出现在任何导出文件里。 - 插件只连这些地方:B站和 YouTube、你配置的 AI 平台、你本机的 Obsidian。允许读取网页以后,侧边栏开着时会在本机读它跟随的每个网页的正文;只有你提问或点 AI 总结时,正文才发给你选的 AI 平台。没有作者的服务器,没有统计。 -- B站 和 YouTube 视频页上的网页脚本(包括你用的 AI agent)可以向插件要这个视频的信息、字幕、章节和热门评论,这些都是网站本来就给这个页面的数据。打开「允许 MCP 连接」后,插件还会连本机 `127.0.0.1:47823` 上的 MCP 服务(`mcp/`),只接受打开 B站 和 YouTube 视频页、在这两个网站搜索视频的请求。再打开它下面的「允许 AI 工具读我的收藏、关注和历史」后,还接受读你 B站 的收藏夹和里面的视频、稍后再看、关注和分组、观看历史的请求,连同分拣台的标签、备注和 AI 判断交给本机的 MCP 服务,再由它交给你用的 AI 工具。只读,每次返回一页,不改 B站 和插件里的任何东西,不含 API Key 和 AI 对话。见 README 的「给 AI agent 用」。 +- B站 和 YouTube 视频页上的网页脚本(包括你用的 AI agent)可以向插件要这个视频的信息、字幕、章节和热门评论,这些都是网站本来就给这个页面的数据。打开「允许 MCP 连接」后,插件还会连本机 `127.0.0.1:47823` 上的 MCP 服务(`mcp/`),只接受打开 B站 和 YouTube 视频页、在这两个网站搜索视频的请求。再打开它下面的「允许 Agent 读我的收藏、关注和历史」后,还接受读你 B站 的收藏夹和里面的视频、稍后再看、关注和分组、观看历史的请求,连同分拣台的标签、备注和 AI 判断交给本机的 MCP 服务,再由它交给你用的 Agent 。只读,每次返回一页,不改 B站 和插件里的任何东西,不含 API Key 和 AI 对话。见 README 的「给 AI agent 用」。 - 分拣台只读取你勾选的收藏夹的内容。为了列出可勾选和可移动到的收藏夹,会读收藏夹的名称和数量。 - 分拣台的「关注」读你的关注列表、关注分组、每个人的粉丝数,以及关注的人新发的视频。只在你打开「关注」或点刷新时读。 - **观看进度**不是「关」时,会读你自己的 B站历史记录:只在你打开分拣台或 B站页面时读,最多 10 分钟一次,只读上次之后新增的部分。每个视频只在本地存两个数:看到百分之多少、最后观看时间,留 1 年、最多 5000 个(勾选收藏夹里的视频不删)。这些数据不发往任何地方。选「关」就不读。 diff --git a/README.md b/README.md index 76da50cd..50126116 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ ## 给 AI agent 用 -Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能借你浏览器的登录状态拿到视频的字幕、章节、简介和热门评论,还能截取任意一秒的画面。用 MCP 时,agent 还能借你的登录搜 B站和 YouTube 视频。不下载视频,不花 AI 额度,读不到你的 Key 和 AI 对话。你的收藏夹、关注、观看历史和分拣台里的标签、备注,只有打开「允许 AI 工具读我的收藏、关注和历史」后才能通过 MCP 读;YouTube 稍后观看和 YouTube 播放列表,再打开「允许 AI 工具改我的收藏」后 agent 才能改。两种接法: +Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能借你浏览器的登录状态拿到视频的字幕、章节、简介和热门评论,还能截取任意一秒的画面。用 MCP 时,agent 还能借你的登录搜 B站和 YouTube 视频。不下载视频,不花 AI 额度,读不到你的 Key 和 AI 对话。你的收藏夹、关注、观看历史和分拣台里的标签、备注,只有打开「允许 Agent 读我的收藏、关注和历史」后才能通过 MCP 读;YouTube 稍后观看和 YouTube 播放列表,再打开「允许 Agent 改我的收藏」后 agent 才能改。两种接法: - **skill**(agent 能操控浏览器,如 ego、Claude in Chrome、Playwright): diff --git a/docs/AGENT.md b/docs/AGENT.md index 01ee635b..f41cc25e 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -81,7 +81,7 @@ A site whose adapter reads the page itself (§5, `read` a function) is read only `data`: `{ site, query, page, items }`, each item `{ site, videoId, url, title, author, duration, views, uploadDate, description }`: `url` is `https://www.bilibili.com/video/BV…` or `https://www.youtube.com/watch?v=…` (ready for `get`); `title`, `author`, `description` plain text (B站's `` highlights and HTML entities removed), `description` at most 200 characters; `duration` seconds, `0` when unknown (a live stream); `views` a number or `null`; `uploadDate` B站 `YYYY-MM-DD` (local day), YouTube as the page writes it ("2 years ago", in the browser's language), `""` when missing. -`favorites`, `follows` and `history` (`extension/agent-mydata.js`) read the user's own Bilibili account in the service worker, no tab. Off by default: while 设置 → MCP → 允许 AI 工具读我的收藏、关注和历史 (`mcpPersonalData`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `PERSONAL_DATA_OFF`. They only GET Bilibili's read endpoints (the 分拣台's channel, `triageBiliGet`) and write nothing, to Bilibili or to storage. They run one at a time, a second apart, and return one page per call. Bilibili is asked for one page, except the folder list (up to 20 pages of folders) and 稍后再看 (all of it in one request); a ticked folder's local copy is read whole, then paged. +`favorites`, `follows` and `history` (`extension/agent-mydata.js`) read the user's own Bilibili account in the service worker, no tab. Off by default: while 设置 → MCP → 允许 Agent 读我的收藏、关注和历史 (`mcpPersonalData`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `PERSONAL_DATA_OFF`. They only GET Bilibili's read endpoints (the 分拣台's channel, `triageBiliGet`) and write nothing, to Bilibili or to storage. They run one at a time, a second apart, and return one page per call. Bilibili is asked for one page, except the folder list (up to 20 pages of folders) and 稍后再看 (all of it in one request); a ticked folder's local copy is read whole, then paged. - `favorites` without `folderId`: `{ folders: [{ id, title, count }] }`, 稍后再看 first with id `"toview"`, then the user's own folders (Bilibili's folder list, read live). With `folderId`: `{ folderId, source, total, page, hasMore, items }`, 40 a page. A folder ticked in the 分拣台 is read from its local copy (`source: "local"`, as of the 分拣台's last read, no Bilibili request); `toview` is read whole from Bilibili in one request; any other folder is one Bilibili page (`source: "bilibili"`). `query`: words that must all appear, case-insensitive, matched on title, author, tags, note and summary for the local copy and `toview`; for other folders Bilibili's own favorites search gets it as `keyword`. An item: `{ bvid, url, title, author, duration, favTime, invalid?, progress?, tags?, note?, verdict?, summary?, kept? }`: `tags` are the user's 分拣 tags (and 摘读记录 tags), `note` the 备注, `verdict` `keep` / `drop` / `unsure` (`MdgTagCore.verdictOf`), `summary` the 细看's one-liner, `kept` the 保留 mark, `progress` seconds watched in 稍后再看 (-1 finished). - `follows`: `{ source, total, page, hasMore, items: [{ mid, name, sign, groups, tags? }] }`, 50 a page. With a synced 关注 list (the 分拣台's 关注) it is read from there (`source: "local"`, with the user's UP 标签 as `tags`, and `query` matched on name, sign, groups and tags); else one Bilibili page of the followings plus the group names (`source: "bilibili"`, `query` is not applied and `queryIgnored: true`). `groups` are 关注分组 names, 特别关注 and 默认分组 included. @@ -89,10 +89,10 @@ A site whose adapter reads the page itself (§5, `read` a function) is read only `collection` and `collectionWrite` (`extension/agent-collections.js`) work on any site whose adapter has `collections` (§5): YouTube today. `kind` is one of the adapter's kinds, else `UNSUPPORTED_KIND`; YouTube has `watch_later`, `playlists`, `playlist` (with `id`), `likes`, `subscriptions`, `history`, and writes to `watch_later` and `playlist` only (`add`, `remove`). Calls run one at a time, a second apart, and stop with `TIMEOUT` while still queued at the deadline. -- `collection`: while 允许 AI 工具读我的收藏、关注和历史 is off, `PERSONAL_DATA_OFF`. `data`: `{ site, kind, id?, items, cursor }`, `cursor` `""` at the end, else passed back for the next page. A video `{ videoId, url, title, author, duration, unavailable? }` (`duration` 0 when unknown), a playlist `{ id, title, count? }`, a channel `{ channelId, url, title }`. -- `collectionWrite`: `items` 1 to 20 distinct ids (YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 AI 工具改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. +- `collection`: while 允许 Agent 读我的收藏、关注和历史 is off, `PERSONAL_DATA_OFF`. `data`: `{ site, kind, id?, items, cursor }`, `cursor` `""` at the end, else passed back for the next page. A video `{ videoId, url, title, author, duration, unavailable? }` (`duration` 0 when unknown), a playlist `{ id, title, count? }`, a channel `{ channelId, url, title }`. +- `collectionWrite`: `items` 1 to 20 distinct ids (YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 Agent 改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. - YouTube: youtubei calls (`browse` with browseId `VLWL`, `VL`, `VLLL`, `FEplaylist_aggregation`, `FEchannels`, `FEhistory`, and `continuation` for later pages; `playlist/get_add_to_playlist` for an item's state, the Save dialog's list of the user's own playlists; `browse/edit_playlist` with `ACTION_ADD_VIDEO` / `ACTION_REMOVE_VIDEO_BY_VIDEO_ID`) made in the bridge's own tab on a youtube.com page (any YouTube page it already shows, else `https://www.youtube.com/`), in the page's MAIN world, with the page's `ytcfg` context and the `SAPISIDHASH` authorization yt-dlp computes (`_get_sid_authorization_header`). Never in the user's tabs. A service worker fetch cannot make these calls: it carries the extension's `Origin`, which fetch cannot change. The endpoint and field names come from yt-dlp and public client code; they are tested against hand-written answers only, not against YouTube. -`triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → MCP → 允许 AI 工具改分拣台 (`mcpWrites`, under the read switch and greyed out while it or 允许 MCP 连接 is off) is off they fail with `TRIAGE_WRITES_OFF`, and while the read switch is off with `PERSONAL_DATA_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). +`triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → MCP → 允许 Agent 改分拣台 (`mcpWrites`, under the read switch and greyed out while it or 允许 MCP 连接 is off) is off they fail with `TRIAGE_WRITES_OFF`, and while the read switch is off with `PERSONAL_DATA_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). - `triage`: `items`, 1 to 50 of `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`, each bvid once; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` (≤ 100 chars); `reason` ≤ 300, `points` ≤ 3 of ≤ 200; they are stored as a 细看 result (`triage_analysis_`, `source: "agent"`). `kept` sets or clears the 保留 mark; `note` (≤ 2000, `""` removes it) the 备注; `addTags` / `removeTags` (≤ 10 names each) the tags of folder `folderId`, which must be ticked and whose local copy holds the video. A missing tag is made while the folder is under 收藏夹设置's tag cap; the others are answered in `tagsNotMade`. Each key is read-modify-written under the lock the 分拣台 takes for it (`MdgLimits.update`, `saveVideoNote`, `keyLock(triage_tags)` for the tag list and map), so neither side loses the other's write. The call is journaled (`triage_agent_log`, the last 20) with each value before and after; the 分拣台 shows each entry as a U step and its U (`agent-undo`) puts back each value still as the tool left it, leaving those the user changed since (a result the 分拣台 stamped with its criteria on open counts as unchanged). A write failing midway keeps and journals what landed before it, and the error names those bvids. `data`: `{ changed, tagsNotMade? }`. - `propose`: `action` `unfav`, `move` or `copy`; `from` a folder ticked in the 分拣台 (or `toview`) whose local copy holds every one of `bvids` (1 to 50); `to`, for `move` and `copy` only, another folder of the list the 分拣台 last read (`triage_folder_list`), never `toview`; `reason` ≤ 200. Nothing is sent to Bilibili: the proposal is queued (`triage_agent_proposals`, at most 20, else `PROPOSALS_FULL`). The 分拣台's progress pill shows it; 查看 opens `from` and asks with the videos still there, 执行 runs the 分拣台's own 取消收藏 / 移动 / 复制 (its rate limits and undo), 拒绝 drops it, Esc leaves it. `data`: `{ id, status: "proposed" }`. @@ -112,7 +112,7 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TOO_LARGE` (a page-run `read` returned more than 300,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → MCP → 允许 AI 工具读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 AI 工具改分拣台), `BAD_PARAMS` (a parameter is wrong, the error says which; nothing was asked or written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (the site did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → MCP → 允许 AI 工具改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TOO_LARGE` (a page-run `read` returned more than 300,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → MCP → 允许 Agent 读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 Agent 改分拣台), `BAD_PARAMS` (a parameter is wrong, the error says which; nothing was asked or written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (the site did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → MCP → 允许 Agent 改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 104f7c08..6adef479 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 两种模式都画的东西,只在 `extension/triage/shared.js`(`TriageUi`)里写一份:标题行、排序、搜索框、筛选片、第 3/4 行的行名(`labeledRow`)、第 4 行的标签片(`tagFilterChip`)、「已出分拣范围」的原因页签(`kindTabs`)、卡片上的「原在…」(`originChip`)、「清理」按钮(`cleanBtn`)和清理前的备份说法(`CLEAN_BACKUP`)、「AI 刚打的」片(`aiRecentChip`)、选中栏(`selbar`)、移动 / 复制弹窗(`askTransfer`:收藏夹传收藏夹,关注传分组)、左栏项(`sideItem`)、刷新和导出按钮、进度胶囊、空状态和里面的刷新(`refreshEmpty`)、头部收起(`headFold`,两种模式各自记在 `triage_head_folded`;列表滚动后加 `head-scrolled` 画头部下沿的阴影;它返回的函数由 `follow.js` 的 `setMode` 在切换 `body.follow-mode` 后立刻调用,新模式的收起状态不播动画地生效)、列表为空的原因(`noMatch`)、撤销前的确认(`undoAsk`)、设置行(`fillSetRows`,包括「AI 打标签每批数量」,数字框的范围和「默认 N」、提示里的数字(`data-num-min` / `data-num-max` / `data-num-default`)都来自 `NUM_SETTINGS`;提示里的数字由 `fillNumHints` 填,设置页 `options.html` 也加载 `shared.js` 用它;提示里引用的固定数(粗看一次请求带几个标题 `TriageUi.TITLES_PER_REQUEST`、粗看和 AI 打标签带的简介字数 `INTRO_CHARS`、细看没字幕时读的热门评论条数 `DEEP_COMMENTS`、笔记的热门评论条数、快捷追问和正文附加段落的上限、AI 读的字幕字数)写成 `data-hint-num`,也由 `fillNumHints` 从代码填)、输出上限留空时的「自动」(`autoMaxTokens`:粗看、AI 打标签、细看各一条公式,后台 `triageMaxTokens` 发请求和收藏夹设置的提示都用;AI 打标签读粗看的输出上限设置)、格式化函数、AI 建议的合并(标签名清洗和标签颜色在 `extension/tag-core.js`)。「收藏夹设置」和「关注设置」两个弹窗的结构都写在 `triage.html` 里。发现两边各写了一份,就合并进去。 - 打标签面板只有一个:`extension/triage/tag-picker.js`。例外:B站 页面上的「+」标签面板(`badges.js`)在封闭的 shadow DOM 里,用不了 tag-picker.js 和扩展页的样式,样子和键盘照它另写一份;标签的纯函数(颜色、标签名清洗、新标签 id、下一个颜色、面板的行、编号、顺序和方向键)放在 `extension/tag-core.js`(`MdgTagCore`)里,`badges.js`、`shared.js`、`follow.js` 和 `fav-*.js` 都用它,manifest 和 `triage.html` 都在它们之前加载。`tag-core.selftest.js` 会查别处有没有再写一份。「标签管理」和「✦ AI 打标签」弹窗也各只有一个:`extension/triage/tag-dialogs.js`;关注的「分组管理」也用「标签管理」这个弹窗,adapter 换掉标题、文字和每行的样子。模式打开它们时只传一个 adapter,里面是单位(视频 / UP 主 / 记录)、上限和数据,文字、布局和确认页的计数(改动数、按标签汇总、「用在 N 个」)都在弹窗里。摘读记录页也用这三个(打标签面板、「标签管理」、「✦ AI 打标签」):页面里只放空的 ``,「✦ AI 打标签」的结构由 `tag-dialogs.js` 写进去,两个页面共用一份;摘读记录没有 T,确认页和「AI 刚打的」提示里「不对的按 T 改」那半句由 adapter 的 `check` 换掉。 -- 标签的数据操作只有一份:`extension/triage/tag-store.js`(`MdgTagStore`),`triage.html` 和 `history.html` 都加载它。新建、改名、换颜色、改说明、删除(确认框的个数)、给条目加减标签、上限和原因、AI 的新建名额(`room` / `noRoom`)、U 的步骤(`pushStep` / `undoLast`,收藏夹的保留和改 B站 的步骤也走这两个)、「✦ AI 打标签」的批次(`runAi`)和「应用」(`apply`:先写,写成了才清建议、标「AI 刚打的」、压 U;写失败留着建议、红字说)都在里面。每个页面 `MdgTagStore.create(adapter)` 一次,adapter 只说不一样的:数据在哪(三种后端:收藏夹 `local`,在 `MdgLimits.keyLock(triage_tags)` 的锁里现读存储再改、经页面自己的 `store` 写回,页面里那份只是副本,后台替 AI 工具写标签(`agent-writes.js`)也用这把锁;关注 `follow`,`MdgLimits.updateFollow` 的锁;摘读记录 `marks`,`MdgLimits.updateReadingMarks`,星标也走它的 `update`)、条目(哪些 `live`;关注的已取消关注算进个数 `uses`,U 不改他们 `restorable`)、作用域(收藏夹按收藏夹,`scoped`)、上限和超了怎么办(`hardCap`:摘读记录给出「摘读记录的标签」,超了不建并在确认页说;收藏夹和关注不给,照建)、名词。删掉一个标签(标签管理、U 删掉没人用的新标签)都调页面的 `tagGone`,收藏夹在别的标签页删了标签时(`followShared`)也走它;关注的后端在删标签的同一次写里把它从已取消关注 UP 主的 `follow_unfollowed` 记录上去掉。`conventions.selftest.js` 的 5c 查页面文件不直接写标签键、不自建撤销栈。B站 页面(`badges.js`)和后台不用它,用 `tag-core.js` 的 `applyUpTag`;AI 工具经 MCP 打的标签由 `agent-writes.js` 在上面那把锁里写。 +- 标签的数据操作只有一份:`extension/triage/tag-store.js`(`MdgTagStore`),`triage.html` 和 `history.html` 都加载它。新建、改名、换颜色、改说明、删除(确认框的个数)、给条目加减标签、上限和原因、AI 的新建名额(`room` / `noRoom`)、U 的步骤(`pushStep` / `undoLast`,收藏夹的保留和改 B站 的步骤也走这两个)、「✦ AI 打标签」的批次(`runAi`)和「应用」(`apply`:先写,写成了才清建议、标「AI 刚打的」、压 U;写失败留着建议、红字说)都在里面。每个页面 `MdgTagStore.create(adapter)` 一次,adapter 只说不一样的:数据在哪(三种后端:收藏夹 `local`,在 `MdgLimits.keyLock(triage_tags)` 的锁里现读存储再改、经页面自己的 `store` 写回,页面里那份只是副本,后台替 Agent 写标签(`agent-writes.js`)也用这把锁;关注 `follow`,`MdgLimits.updateFollow` 的锁;摘读记录 `marks`,`MdgLimits.updateReadingMarks`,星标也走它的 `update`)、条目(哪些 `live`;关注的已取消关注算进个数 `uses`,U 不改他们 `restorable`)、作用域(收藏夹按收藏夹,`scoped`)、上限和超了怎么办(`hardCap`:摘读记录给出「摘读记录的标签」,超了不建并在确认页说;收藏夹和关注不给,照建)、名词。删掉一个标签(标签管理、U 删掉没人用的新标签)都调页面的 `tagGone`,收藏夹在别的标签页删了标签时(`followShared`)也走它;关注的后端在删标签的同一次写里把它从已取消关注 UP 主的 `follow_unfollowed` 记录上去掉。`conventions.selftest.js` 的 5c 查页面文件不直接写标签键、不自建撤销栈。B站 页面(`badges.js`)和后台不用它,用 `tag-core.js` 的 `applyUpTag`;Agent 经 MCP 打的标签由 `agent-writes.js` 在上面那把锁里写。 - 标签和这些弹窗的样式只有一份:`extension/triage/tags.css`(标签片、打标签面板、「标签管理」「✦ AI 打标签」和它们所在的 ``),`triage.html` 和 `history/history.html` 都在自己的样式表之后加载它。`` 的规则写成 `dialog:where(:not(.mdg-overwrite))`,和原来的 `dialog` 一样具体,又不碰 `tokens.css` 管的「该笔记已存在」。挪进来时分拣台每个元素的计算样式一项不差(页面、三个弹窗和分拣台的其他弹窗逐个核对过);摘读记录的打标签面板和「标签管理」因此和分拣台一样了。 - 摘读记录页的头部和卡片用分拣台的样子:`history.html` 在 `history.css` 之前加载 `triage/triage.css`,头部的行名、筛选片、`.act-btn`、全选、搜索框、选中栏、`.card`、`.badge` 和空状态都用它的规则和 `TriageUi` 的标记(`labeledRow`、`stateGroup`、`filterBtn`、`aiRecentChip`、`tagFilterChip`、`tagButtons`、`gearBtn`、`selectAllBox`、`selbar`、`actBtn`、`searchBox`、`bindSearch`、`resultCount`、`cardTagChip`、`emptyState`、`noMatch`);`history.css` 只写页面的排法、头部白卡和卡片里的列。分拣台的头部靠四行各自的 `::before` 拼成白卡(列表从底下滚过),摘读记录是窗口滚动、头部不收起,所以白卡就是一个 `
` 的边框,不用那套拼法。改 `triage.css` 里这些规则时两个页面一起看。 - 所有输入框和快捷键都要走 `extension/typing.js`(`MdgTyping`): diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b00edd82..935f5776 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -342,7 +342,7 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准 - 说明文字不要散在外面。改成按钮后面加一个琥珀色小点,点开以后说明写在里面,通常放进输入框的 placeholder。 - 设置页「AI 平台」的「视频」「网页」是页签,照 B站 空间页的页签:16px,当前的加粗、下面 3px 粉线,旁边小灰字写用在哪(「B站 · YouTube」「其他网站」),一次只显示一组;左右方向键切换。每次打开设置页在「视频」。为什么:两组的项同名,摆在一起分不清在改哪一组(2026-10-22 用户定)。 -- 设置页里只在另一个开关开着时才起作用的开关(「允许 AI 工具读我的收藏、关注和历史」之于「允许 MCP 连接」,「允许 AI 工具改分拣台」和「允许 AI 工具改我的收藏」之于前两个,按这个顺序排)放在那一行下面,左边缩进 24px,两行之间没有分隔线。上面那个关着时它变灰、不能点,悬停写「先打开「允许 MCP 连接」」(关着的有两个时写最上面那个),说明文字里也写这一句;它自己的开关状态照存,上面那个再打开时恢复原样,不自动打开也不清掉。为什么:看得出从属关系,又不让关掉上层就丢了下层的选择(2026-11-06 用户定)。 +- 设置页里只在另一个开关开着时才起作用的开关(「允许 Agent 读我的收藏、关注和历史」之于「允许 MCP 连接」,「允许 Agent 改分拣台」和「允许 Agent 改我的收藏」之于前两个,按这个顺序排)放在那一行下面,左边缩进 24px,两行之间没有分隔线。上面那个关着时它变灰、不能点,悬停写「先打开「允许 MCP 连接」」(关着的有两个时写最上面那个),说明文字里也写这一句;它自己的开关状态照存,上面那个再打开时恢复原样,不自动打开也不清掉。为什么:看得出从属关系,又不让关掉上层就丢了下层的选择(2026-11-06 用户定)。 #### 定下来的决定(改之前先问用户) diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 2cf8f750..9e34434e 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -68,11 +68,11 @@ 分拣台深色主题:同一个细看完成步骤、观看进度标记和展开的待播

-## AI 工具(MCP) +## Agent(MCP) -在设置 → MCP 打开「允许 AI 工具改分拣台」后,Claude Code、Cursor 这类 AI 工具可以通过 [MCP 服务](../mcp/README.md) 写入 AI 判断、保留、备注和视频标签。它写的判断卡片上标「AI 工具判断」;每次写入在分拣台里是一步,按 U 撤销,你之后又改过的不动。 +在设置 → MCP 打开「允许 Agent 改分拣台」后,Claude Code、Cursor 这类 Agent 可以通过 [MCP 服务](../mcp/README.md) 写入 AI 判断、保留、备注和视频标签。它写的判断卡片上标「 Agent 判断」;每次写入在分拣台里是一步,按 U 撤销,你之后又改过的不动。 -取消收藏、移动和复制它只能提议。进度胶囊写「AI 工具的 N 条收藏夹提议等你确认」,点「查看」打开那个收藏夹、列出这些视频:确认后照分拣台自己的操作在 B站执行(按 U 撤销),点「拒绝」删掉提议,B站不变。 +取消收藏、移动和复制它只能提议。进度胶囊写「 Agent 的 N 条收藏夹提议等你确认」,点「查看」打开那个收藏夹、列出这些视频:确认后照分拣台自己的操作在 B站执行(按 U 撤销),点「拒绝」删掉提议,B站不变。 ## 快捷键 diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index ea08b0e1..840690d8 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -17,9 +17,9 @@ // The MCP switches the agent interface's methods check (docs/AGENT.md §4): each one's code and sentence. const MCP_SWITCHES = { - mcpPersonalData: ["PERSONAL_DATA_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 AI 工具读我的收藏、关注和历史 (Settings → MCP) in MoonDigest, then call again."], - mcpWrites: ["TRIAGE_WRITES_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 AI 工具改分拣台 (Settings → MCP) in MoonDigest, then call again."], - mcpCollectionWrite: ["COLLECTION_WRITES_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 AI 工具改我的收藏 (Settings → MCP) in MoonDigest, then call again."] + mcpPersonalData: ["PERSONAL_DATA_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 Agent 读我的收藏、关注和历史 (Settings → MCP) in MoonDigest, then call again."], + mcpWrites: ["TRIAGE_WRITES_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 Agent 改分拣台 (Settings → MCP) in MoonDigest, then call again."], + mcpCollectionWrite: ["COLLECTION_WRITES_OFF", "The user has not allowed this. Ask the user to turn on 设置 → MCP → 允许 Agent 改我的收藏 (Settings → MCP) in MoonDigest, then call again."] }; // An Error carrying the code of the first of keys that is off, else null. async function switchOff(...keys) { diff --git a/extension/agent-collections.selftest.js b/extension/agent-collections.selftest.js index 66f2e211..a0987ae6 100644 --- a/extension/agent-collections.selftest.js +++ b/extension/agent-collections.selftest.js @@ -87,7 +87,7 @@ check("off: list is PERSONAL_DATA_OFF, a write COLLECTION_WRITES_OFF, a dryRun r sync = { mcpPersonalData: true }; const r = await write({ op: "add", kind: "watch_later", items: [ID(2)] }); assert.deepStrictEqual([r.ok, r.code], [false, "COLLECTION_WRITES_OFF"]); - assert.match(r.error, /允许 AI 工具改我的收藏/); + assert.match(r.error, /允许 Agent 改我的收藏/); sync = { mcpPersonalData: true, mcpCollectionWrite: "true" }; // not a boolean: stays off assert.strictEqual((await write({ op: "add", kind: "watch_later", items: [ID(2)] })).code, "COLLECTION_WRITES_OFF"); assert.strictEqual(calls.length, 0); diff --git a/extension/agent-mydata.js b/extension/agent-mydata.js index de589878..6b569cae 100644 --- a/extension/agent-mydata.js +++ b/extension/agent-mydata.js @@ -1,6 +1,6 @@ // MoonDigest's agent interface, docs/AGENT.md §2: the user's own Bilibili data (favorites, 稍后再看, follows, watch // history) for the MCP server, read only. Only GETs, through triage-bg.js's B站 channel (triageBiliGet), one call at a -// time, one page per call (稍后再看 and the 分拣台's local copies are read whole, then paged). Off until 设置 → MCP → 允许 AI 工具读我的收藏、关注和历史 (mcpPersonalData). +// time, one page per call (稍后再看 and the 分拣台's local copies are read whole, then paged). Off until 设置 → MCP → 允许 Agent 读我的收藏、关注和历史 (mcpPersonalData). // The worker loads it after triage-bg.js and follow-core.js, whose helpers it uses. (() => { const K = MdgLimits.KEYS; diff --git a/extension/agent-mydata.selftest.js b/extension/agent-mydata.selftest.js index 3db3b16d..fab7def5 100644 --- a/extension/agent-mydata.selftest.js +++ b/extension/agent-mydata.selftest.js @@ -98,7 +98,7 @@ check("switch off: PERSONAL_DATA_OFF for every method, nothing fetched", async ( const r = await M[name]({}); assert.strictEqual(r.ok, false); assert.strictEqual(r.code, "PERSONAL_DATA_OFF"); - assert.match(r.error, /Ask the user to turn on 设置 → MCP → 允许 AI 工具读我的收藏、关注和历史/); + assert.match(r.error, /Ask the user to turn on 设置 → MCP → 允许 Agent 读我的收藏、关注和历史/); } sync = { mcpPersonalData: false }; assert.strictEqual((await M.favorites({})).code, "PERSONAL_DATA_OFF"); diff --git a/extension/agent-writes.js b/extension/agent-writes.js index ee1727eb..e631405f 100644 --- a/extension/agent-writes.js +++ b/extension/agent-writes.js @@ -1,5 +1,5 @@ // MoonDigest's agent interface, docs/AGENT.md §2: what an AI tool may change in the 分拣台, off until 设置 → MCP → 允许 -// AI 工具改分拣台 (mcpWrites). +// Agent 改分拣台 (mcpWrites). // - triage writes local results at once (AI 判断, 保留, 备注, 视频标签), each key read-modify-written under the lock the // 分拣台 takes for it, and journals the change (KEYS.triageAgentLog): the 分拣台 offers it to U, and agent-undo puts back // what is still as the tool left it. diff --git a/extension/agent-writes.selftest.js b/extension/agent-writes.selftest.js index c4ad6e45..d698aaa9 100644 --- a/extension/agent-writes.selftest.js +++ b/extension/agent-writes.selftest.js @@ -80,7 +80,7 @@ const log = () => local[K.triageAgentLog] || []; check("switch off: TRIAGE_WRITES_OFF for both methods; on but the read switch off: PERSONAL_DATA_OFF; nothing written", async () => { const before = plain(local); - for (const [s, code, words] of [[{ mcpPersonalData: true }, "TRIAGE_WRITES_OFF", /允许 AI 工具改分拣台/], [{ mcpWrites: true }, "PERSONAL_DATA_OFF", /允许 AI 工具读我的收藏/]]) { + for (const [s, code, words] of [[{ mcpPersonalData: true }, "TRIAGE_WRITES_OFF", /允许 Agent 改分拣台/], [{ mcpWrites: true }, "PERSONAL_DATA_OFF", /允许 Agent 读我的收藏/]]) { sync = s; for (const [name, p] of [["triage", { items: [{ bvid: A, kept: true }] }], ["propose", { action: "unfav", from: "11", bvids: [A] }]]) { const r = await M[name](p); diff --git a/extension/options.css b/extension/options.css index 15d59fa4..08ed70b2 100644 --- a/extension/options.css +++ b/extension/options.css @@ -391,7 +391,7 @@ main { justify-self: end; } -/* A switch that works only while the one above it is on (允许 AI 工具读… under 允许 MCP 连接): indented under it, no rule +/* A switch that works only while the one above it is on (允许 Agent 读… under 允许 MCP 连接): indented under it, no rule between them, greyed out while that one is off. */ .row.sub { padding-top: 0; diff --git a/extension/options.html b/extension/options.html index 89aaff12..0ad3adc7 100644 --- a/extension/options.html +++ b/extension/options.html @@ -344,19 +344,19 @@

MCP

-

让 Cursor、Codex、Claude Code 等 AI 工具通过本机的 MCP 服务 搜索 B站和 YouTube 视频,读视频的字幕、评论和画面。用 MCP 时再打开:开着时插件每分钟试连一次本机 127.0.0.1:47823,连上后插件后台会一直运行。不用时关掉,能省一点电和内存。

+

让 Cursor、Codex、Claude Code 等 Agent 通过本机的 MCP 服务 搜索 B站和 YouTube 视频,读视频的字幕、评论和画面。用 MCP 时再打开:开着时插件每分钟试连一次本机 127.0.0.1:47823,连上后插件后台会一直运行。不用时关掉,能省一点电和内存。

-

AI 工具还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

+

Agent 还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。

-

AI 工具还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 AI 工具读我的收藏、关注和历史」。

+

Agent 还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 Agent 读我的收藏、关注和历史」。

-

AI 工具还能通过 MCP 把视频加进或移出你的 YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,YouTube 限流就停。要先打开「允许 AI 工具读我的收藏、关注和历史」。

+

Agent 还能通过 MCP 把视频加进或移出你的 YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,YouTube 限流就停。要先打开「允许 Agent 读我的收藏、关注和历史」。

diff --git a/extension/options.js b/extension/options.js index 0dcdc232..f2b3d035 100644 --- a/extension/options.js +++ b/extension/options.js @@ -690,12 +690,12 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. -// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +// 允许 Agent 读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. // The two write switches (改分拣台, 改我的收藏) hang on both the same way: a write reads first. function syncMcpRows() { const mcp = elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"; MdgErrors.setReason(elements.mcpPersonalData, mcp); - const read = mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 AI 工具读我的收藏、关注和历史」"); + const read = mcp || (elements.mcpPersonalData.checked ? "" : "先打开「允许 Agent 读我的收藏、关注和历史」"); for (const box of [elements.mcpWrites, elements.mcpCollectionWrite]) MdgErrors.setReason(box, read); } diff --git a/extension/options.selftest.js b/extension/options.selftest.js index 998ff263..7e9b713a 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -523,7 +523,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "mcpCollectionWrite", "mcpWrites", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); - // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. + // 允许 Agent 读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. assert.deepStrictEqual([els.mcpEnabled.checked, els.mcpPersonalData.checked, els.mcpPersonalData.disabled], [true, true, false]); els.mcpEnabled.checked = false; page.syncMcpRows(); @@ -541,7 +541,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh assert.deepStrictEqual(writes(), [[true, "先打开「允许 MCP 连接」"], [true, "先打开「允许 MCP 连接」"]]); els.mcpEnabled.checked = true; page.syncMcpRows(); - assert.deepStrictEqual(writes(), [[true, "先打开「允许 AI 工具读我的收藏、关注和历史」"], [true, "先打开「允许 AI 工具读我的收藏、关注和历史」"]]); + assert.deepStrictEqual(writes(), [[true, "先打开「允许 Agent 读我的收藏、关注和历史」"], [true, "先打开「允许 Agent 读我的收藏、关注和历史」"]]); assert.deepStrictEqual([page.readFormPayload().mcpWrites, page.readFormPayload().mcpCollectionWrite], [true, true], "greyed out, they keep their own values"); els.mcpPersonalData.checked = true; page.syncMcpRows(); diff --git a/extension/settings.js b/extension/settings.js index e8976fb7..2dba7fa4 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -82,11 +82,11 @@ enableDebugLogs: false, // The agent interface's MCP socket (agent-bridge.js in the background, docs/AGENT.md): off until 设置 → MCP → 允许 MCP 连接. mcpEnabled: false, - // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. + // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 Agent 读我的收藏、关注和历史. mcpPersonalData: false, - // It may also change them (agent-collections.js, docs/AGENT.md §2 collectionWrite): off until 允许 AI 工具改我的收藏. + // It may also change them (agent-collections.js, docs/AGENT.md §2 collectionWrite): off until 允许 Agent 改我的收藏. mcpCollectionWrite: false, - // The MCP server may also change the 分拣台's results and propose B站 favorites changes (agent-writes.js): off until 允许 AI 工具改分拣台. + // The MCP server may also change the 分拣台's results and propose B站 favorites changes (agent-writes.js): off until 允许 Agent 改分拣台. mcpWrites: false, readerTheme: "light", readerFontScale: "m", diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index 12ebe609..b3f4e57f 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -183,7 +183,7 @@ const ownSync = () => Boolean(S.syncing?.alive); runs.put("sync", () => ownSync() && { text: `${UI.REFRESHING}…${pageText(S.mediaId)}` }); runs.put("proposal", () => S.ai.proposal && { text: UI.AI_TAGS_PENDING }); runs.put("aiOther", () => otherAiFolder() && { text: `「${folderName(otherAiFolder())}」的${UI.AI_TAGS_PENDING}` }); -runs.put("agent", () => S.agentProposals.length > 0 && { text: `AI 工具的 ${S.agentProposals.length} 条收藏夹提议等你确认` }); +runs.put("agent", () => S.agentProposals.length > 0 && { text: `Agent 的 ${S.agentProposals.length} 条收藏夹提议等你确认` }); // 观看进度's 30-minute pause on B站 risk control comes from the background (triage_seen_meta); tick() redraws it. runs.put("seen", () => S.seenCfg.on && S.seenRead.until > Date.now() && { text: MdgErrors.LABEL.seen, wait: { until: S.seenRead.until, label: THROTTLES.THROTTLED.label } }); runs.put("seenRead", () => S.seenCfg.on && S.seenRead.busy && S.seenRead.pages && { text: `读观看进度… 已读 ${S.seenRead.pages} 页` }); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index dd018554..37b86e09 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -447,7 +447,7 @@ const KEEP_TIP = `保留标记${MdgErrors.TEXT.localOnly}`; const DONE_TIP = `已保留:${KEEP_TIP}。\n已取消收藏:已从 B站收藏夹移走,最近的操作可按 U 撤销。`; // A verdict an AI tool wrote over MCP (agent-writes.js), on the card's meta line in place of the basis. -const AGENT_VERDICT = "AI 工具判断"; +const AGENT_VERDICT = "Agent 判断"; // What a 细看 judged from, on the card's meta line, in the video page's words: no subtitle (and why), or a subtitle // whose time range may not match the video. Nothing for a matching subtitle. function basisText(a) { diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 830c78cc..4d338837 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -484,7 +484,7 @@ function agentStep(e) { return { kind: "agent", agentId: e.id, - ask: e.n > 1 ? [`撤销 AI 工具的修改?`, `

AI 工具改了 ${e.n} 个视频的 AI 判断、保留、备注或标签,撤销后改回去;你之后又改过的不动。

`, "撤销"] : null, + ask: e.n > 1 ? [`撤销 Agent 的修改?`, `

Agent 改了 ${e.n} 个视频的 AI 判断、保留、备注或标签,撤销后改回去;你之后又改过的不动。

`, "撤销"] : null, undo: async () => { const r = await send({ type: "agent-undo", id: e.id }); if (!r.ok) { @@ -495,7 +495,7 @@ function agentStep(e) { return ""; } const { n, skipped } = r.data; - return redraw() || `已撤销:AI 工具的修改(${n} 个视频${skipped ? `,${skipped} 个你之后改过,没动` : ""})`; + return redraw() || `已撤销:Agent 的修改(${n} 个视频${skipped ? `,${skipped} 个你之后改过,没动` : ""})`; } }; } @@ -512,7 +512,7 @@ function followAgentLog(log, tell = true) { S.agentLog = log; const n = log.filter((e) => e.at > agentSeenAt).reduce((sum, e) => sum + e.n, 0); agentSeenAt = Math.max(agentSeenAt, ...log.map((e) => e.at)); - if (tell && n) toast(`AI 工具改了 ${n} 个视频的分拣结果${UNDO}`); + if (tell && n) toast(`Agent 改了 ${n} 个视频的分拣结果${UNDO}`); } // Its B站 proposals, oldest first: 查看 opens the proposal's folder and asks. 执行 runs it as the page's own 取消收藏 / @@ -554,12 +554,12 @@ async function reviewProposal() { gone && list.length && `

另有 ${gone} 个已经不在「${esc(folderName(p.from))}」里,跳过。

`, `

拒绝会删掉这条提议,B站不变。

` ].filter(Boolean).join(""); - const title = `AI 工具提议:${verb} ${list.length || p.bvids.length} 个视频${p.to ? `到「${folderName(p.to)}」` : ""}`; + const title = `Agent 提议:${verb} ${list.length || p.bvids.length} 个视频${p.to ? `到「${folderName(p.to)}」` : ""}`; const ok = await askConfirm(title, body, `${verb} ${list.length} 个`, { danger: p.action !== "copy", reason: why, cancelText: "拒绝" }); if (!ok && el.confirmDialog.returnValue !== "cancel") return; // Taken out of the queue first, so another 分拣台 tab cannot run it too; a batch that started meanwhile puts it back. if (!(await dropProposal(p.id))) return toast("这条提议已在别的分拣台标签页处理", true); - if (!ok) return toast("已拒绝 AI 工具的提议"); + if (!ok) return toast("已拒绝 Agent 的提议"); if (W.busy()) return restoreProposal(p).then(() => toast(UI.busyText(W.busy()), true)); if (p.action === "unfav") await fav.unfavMany(list, [p.from]); else await fav.transfer(list, { how: p.action, target: { id: p.to } }); diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index d8553818..c659de98 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -4581,10 +4581,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = const entry = (id, at, n = 1) => ({ id, at, n, changes: {}, made: [] }); toasts.length = 0; t.followAgentLog([entry("a1", 10)]); - assert.deepStrictEqual([t.S.undo.map((s) => s.agentId), toasts.at(-1)], [["a1"], `AI 工具改了 1 个视频的分拣结果${t.TriageUi.UNDO}`]); + assert.deepStrictEqual([t.S.undo.map((s) => s.agentId), toasts.at(-1)], [["a1"], `Agent 改了 1 个视频的分拣结果${t.TriageUi.UNDO}`]); handlers["agent-undo"] = (m) => ({ ok: true, data: { n: 1, skipped: m.id === "a1" ? 1 : 0 } }); await t.undo(); - assert.deepStrictEqual([sent.at(-1).type, sent.at(-1).id, toasts.at(-1), t.S.undo.length], ["agent-undo", "a1", "已撤销:AI 工具的修改(1 个视频,1 个你之后改过,没动)", 0]); + assert.deepStrictEqual([sent.at(-1).type, sent.at(-1).id, toasts.at(-1), t.S.undo.length], ["agent-undo", "a1", "已撤销:Agent 的修改(1 个视频,1 个你之后改过,没动)", 0]); t.followAgentLog([]); t.followAgentLog([entry("a2", 20, 3)]); t.S.undo.push({ kind: "keepMany", undo: async () => "" }); // a step of the user's own, after the tool's @@ -4626,17 +4626,17 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = handlers["triage-unfav"] = (m) => (unfavs.push(m), { ok: true }); handlers["triage-transfer"] = (m) => (moves.push(m), { ok: true }); propose([{ id: "p1", at: 1, action: "unfav", from: "P", bvids: ["BV41", "BV49"], reason: "重复" }, { id: "p2", at: 2, action: "move", from: "P", to: "Q", bvids: ["BV42", "BV43"] }]); - assert.strictEqual(t.activityState().text, "AI 工具的 2 条收藏夹提议等你确认"); + assert.strictEqual(t.activityState().text, "Agent 的 2 条收藏夹提议等你确认"); answer(false, ""); await t.reviewProposal(); assert.deepStrictEqual([store[t.K.agentProposals].length, unfavs.length], [2, 0], "Esc leaves it waiting"); - assert.deepStrictEqual(plain(asked.at(-1)), ["AI 工具提议:取消收藏 1 个视频", "取消收藏 1 个", { danger: true, reason: "", cancelText: "拒绝" }], "BV49 is not in the folder: skipped"); + assert.deepStrictEqual(plain(asked.at(-1)), ["Agent 提议:取消收藏 1 个视频", "取消收藏 1 个", { danger: true, reason: "", cancelText: "拒绝" }], "BV49 is not in the folder: skipped"); answer(true, "ok"); await t.reviewProposal(); assert.deepStrictEqual(plain([unfavs.map((m) => [m.mediaId, m.aids]), store[t.K.agentProposals].map((p) => p.id), asked.length]), [[["P", [1041]]], ["p2"], 2], "执行: one B站 request, no second dialog"); assert.strictEqual(t.S.decisions.BV41.action, "unfav"); await t.reviewProposal(); - assert.deepStrictEqual(plain([moves.map((m) => [m.from, m.to, m.aids, m.move]), store[t.K.agentProposals], asked.at(-1)[0]]), [[["P", "Q", [1042, 1043], true]], [], "AI 工具提议:移动 2 个视频到「目标」"]); + assert.deepStrictEqual(plain([moves.map((m) => [m.from, m.to, m.aids, m.move]), store[t.K.agentProposals], asked.at(-1)[0]]), [[["P", "Q", [1042, 1043], true]], [], "Agent 提议:移动 2 个视频到「目标」"]); // 拒绝, and a running batch refuses 查看 before asking. propose([{ id: "p3", at: 3, action: "copy", from: "P", to: "Q", bvids: ["BV42"] }]); let release = null; @@ -4651,7 +4651,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = answer(false, "cancel"); const before = moves.length; await t.reviewProposal(); - assert.deepStrictEqual([store[t.K.agentProposals], moves.length, toasts.at(-1)], [[], before, "已拒绝 AI 工具的提议"]); + assert.deepStrictEqual([store[t.K.agentProposals], moves.length, toasts.at(-1)], [[], before, "已拒绝 Agent 的提议"]); // From a folder no longer ticked: nothing to run, only 拒绝. propose([{ id: "p4", at: 4, action: "unfav", from: "Z", bvids: ["BV41"] }]); answer(false, "cancel"); diff --git a/mcp/README.md b/mcp/README.md index a901a298..a978d46c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -18,7 +18,7 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── - `get_video({ url, raw? })`:标题、作者、日期、时长、章节、带时间戳的全文字幕、热评。 - `get_frame({ url, at, maxWidth? })`:`at` 秒处的一帧 JPEG,外加一行实际时间。 -读你自己 B站 数据的三个工具,都只读,默认关闭,要在「允许 MCP 连接」下面再打开「允许 AI 工具读我的收藏、关注和历史」,关着时返回 `PERSONAL_DATA_OFF`: +读你自己 B站 数据的三个工具,都只读,默认关闭,要在「允许 MCP 连接」下面再打开「允许 Agent 读我的收藏、关注和历史」,关着时返回 `PERSONAL_DATA_OFF`: - `get_favorites({ folderId?, page?, query?, raw? })`:不给 `folderId` 时列出收藏夹(稍后再看在最前,id 是 `toview`);给了就列这个收藏夹的视频,每页 40 个,带分拣台的标签、备注、AI 判断和一句话总结。分拣台勾选过的收藏夹读本地那份,`query` 搜标题、UP、标签、备注和总结;没勾选的每次从 B站 读一页,`query` 交给 B站 的收藏夹搜索(只搜标题)。 - `get_follows({ page?, query?, raw? })`:关注的 UP 主,每页 50 个,带关注分组和你的 UP 标签。分拣台的「关注」同步过就读本地那份,`query` 能用;没同步过就从 B站 读一页,`query` 不生效,结果里会写明。 @@ -26,14 +26,14 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 这三个工具一次只读一页,同一时间只跑一个,不会一口气读完。 -你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,AI 工具就是界面: +你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,Agent 就是界面: - `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `youtube`,`kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 -改分拣台的两个工具,默认关闭,要在「允许 AI 工具读我的收藏、关注和历史」下面再打开「允许 AI 工具改分拣台」,关着时返回 `TRIAGE_WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: +改分拣台的两个工具,默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改分拣台」,关着时返回 `TRIAGE_WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: -- `set_triage({ items })`:一次最多 50 个视频,每个 `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`,只改给了的字段。`verdict`(`keep` / `drop` / `unsure`)要带 `summary`,和分拣台细看的结论一样显示,卡片上标「AI 工具判断」;`kept` 是保留标记;`note` 是备注,空字符串删掉;`addTags` / `removeTags` 是 `folderId` 这个收藏夹的标签名,视频要在这个勾选过的收藏夹里,没有的标签在名额内新建,满了不建并在结果里列出。只改本地,不改 B站。每次调用在分拣台里是一步,按 U 撤销;撤销时你之后又改过的不动。 +- `set_triage({ items })`:一次最多 50 个视频,每个 `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`,只改给了的字段。`verdict`(`keep` / `drop` / `unsure`)要带 `summary`,和分拣台细看的结论一样显示,卡片上标「 Agent 判断」;`kept` 是保留标记;`note` 是备注,空字符串删掉;`addTags` / `removeTags` 是 `folderId` 这个收藏夹的标签名,视频要在这个勾选过的收藏夹里,没有的标签在名额内新建,满了不建并在结果里列出。只改本地,不改 B站。每次调用在分拣台里是一步,按 U 撤销;撤销时你之后又改过的不动。 - `propose_fav_change({ action, from, to?, bvids, reason? })`:提议在 B站 取消收藏(`unfav`)、移动(`move`)或复制(`copy`)。它自己不改 B站:提议在分拣台的进度胶囊里等你,点「查看」打开 `from` 收藏夹列出这些视频,确认后照分拣台自己的操作执行(限流、按 U 撤销都一样),也可以拒绝。`from` 要是分拣台勾选过的收藏夹(或 `toview`),视频都在它本地那份里;`to` 是收藏夹列表里的另一个收藏夹,不能是稍后再看。最多 20 条提议同时等待,满了返回 `PROPOSALS_FULL`。 `get_video` 默认返回紧凑文本,每条字幕一行 `[mm:ss] 内容`(超过一小时用 `hh:mm:ss`)。扩展原始 JSON 每条字幕都带 `from`、`to`、`content` 三个键名和小数时间,实测体积约是文本的两倍,而模型几乎用不到结束时间。确实要精确时间时传 `raw: true`。 @@ -94,7 +94,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 开关默认关闭。关着的时候扩展不建任何连接。 - 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只读,不改 B站 和 YouTube,也不改插件存的数据;API Key 和 AI 对话不会给出去。 - 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 -- 改分拣台也另有一个开关,默认关闭,和改收藏一样,上面两个有一个关着它就变灰。打开后 AI 工具只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 +- 改分拣台也另有一个开关,默认关闭,和改收藏一样,上面两个有一个关着它就变灰。打开后 Agent 只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 ### 两个 MCP 客户端同时开着 @@ -116,9 +116,9 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - `TIMEOUT`:60 秒内没有回答。常见原因是页面加载太慢,或后台标签页被浏览器冻结。切到那个标签页看一眼再试。 - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 -- `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 -- `COLLECTION_WRITES_OFF`:「允许 AI 工具改我的收藏」没开。 -- `TRIAGE_WRITES_OFF`:「允许 AI 工具改分拣台」没开。 +- `PERSONAL_DATA_OFF`:「允许 Agent 读我的收藏、关注和历史」没开。 +- `COLLECTION_WRITES_OFF`:「允许 Agent 改我的收藏」没开。 +- `TRIAGE_WRITES_OFF`:「允许 Agent 改分拣台」没开。 - `BAD_PARAMS`:参数不对,错误里写着哪一项;这次什么都没写。 - `PROPOSALS_FULL`:已经有 20 条提议在分拣台等你处理。 - `NOT_LOGGED_IN`:这个浏览器没登录 B站(收藏工具:没登录 YouTube)。 @@ -145,7 +145,7 @@ Three tools search and read videos: - `get_video({ url, raw? })`: title, author, date, duration, chapters, the full timestamped transcript, hot comments. - `get_frame({ url, at, maxWidth? })`: a JPEG frame at `at` seconds, plus a line with the actual time. -Three tools read your own Bilibili data. All are read-only and off by default: turn on 允许 AI 工具读我的收藏、关注和历史 under 允许 MCP 连接. While it is off they return `PERSONAL_DATA_OFF`. +Three tools read your own Bilibili data. All are read-only and off by default: turn on 允许 Agent 读我的收藏、关注和历史 under 允许 MCP 连接. While it is off they return `PERSONAL_DATA_OFF`. - `get_favorites({ folderId?, page?, query?, raw? })`: without `folderId`, the folders (稍后再看 first, id `toview`); with it, that folder's videos, 40 a page, with your 分拣 tags, note, AI verdict and one-line summary. A folder ticked in the 分拣台 is read from its local copy and `query` searches title, UP, tags, note and summary; any other folder is read from Bilibili a page at a time and `query` goes to Bilibili's favorites search (titles only). - `get_follows({ page?, query?, raw? })`: the UP 主 you follow, 50 a page, with follow groups and your UP tags. Read from the 分拣台's synced follow list when there is one (then `query` works); otherwise one page from Bilibili, where `query` is not applied and the result says so. @@ -156,11 +156,11 @@ These return one page per call and run one at a time, a second apart. Two tools work on your own YouTube collections. MoonDigest has no pages for them: the AI tool is the UI. - `list_collection({ site, kind, id?, cursor?, raw? })`: `site` is `youtube`; `kind` is `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 AI 工具改我的收藏 under 允许 AI 工具读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 Agent 改我的收藏 under 允许 Agent 读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. -Two tools change the 分拣台. Both are off by default: turn on 允许 AI 工具改分拣台 under 允许 AI 工具读我的收藏、关注和历史. While it is off they return `TRIAGE_WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. +Two tools change the 分拣台. Both are off by default: turn on 允许 Agent 改分拣台 under 允许 Agent 读我的收藏、关注和历史. While it is off they return `TRIAGE_WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. -- `set_triage({ items })`: up to 50 videos, each `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` and shows like a 细看's, marked AI 工具判断 on the card. `kept` is the 保留 mark. `note` is the note, `""` removes it. `addTags` / `removeTags` are tag names of folder `folderId`, which must be ticked and hold the video; a missing tag is made while the folder has room, else it is not made and the result lists it. Local only, nothing on Bilibili. Each call is one step in the 分拣台 that U undoes; what you changed since stays. +- `set_triage({ items })`: up to 50 videos, each `{ bvid, verdict?, summary?, reason?, points?, kept?, note?, folderId?, addTags?, removeTags? }`; only the fields given change. `verdict` (`keep` / `drop` / `unsure`) needs `summary` and shows like a 细看's, marked Agent 判断 on the card. `kept` is the 保留 mark. `note` is the note, `""` removes it. `addTags` / `removeTags` are tag names of folder `folderId`, which must be ticked and hold the video; a missing tag is made while the folder has room, else it is not made and the result lists it. Local only, nothing on Bilibili. Each call is one step in the 分拣台 that U undoes; what you changed since stays. - `propose_fav_change({ action, from, to?, bvids, reason? })`: proposes unfav, move or copy on Bilibili. It changes nothing itself: the proposal waits in the 分拣台's progress pill; 查看 opens folder `from` with the videos listed, and confirming runs it as the 分拣台's own action (same rate limits, U undoes it), or you reject it. `from` must be a folder ticked in the 分拣台 (or `toview`) whose local copy has every video; `to` another folder of the folder list, not 稍后再看. At most 20 proposals wait at once; past that `PROPOSALS_FULL`. `get_video` returns compact text by default, one transcript line per segment as `[mm:ss] text` (`hh:mm:ss` past an hour). The extension's raw JSON repeats the `from`, `to` and `content` keys and fractional times on every segment, which measures about twice the size, and models rarely need end times. Pass `raw: true` when you do need exact times. @@ -243,9 +243,9 @@ With no extension connected, a call returns `NOT_CONNECTED` at once, asking for - `TIMEOUT`: no answer in 60 s. Usually the page loaded too slowly or the browser froze the background tab. Look at that tab and try again. - The subtitle says log in (`Subtitle note`): log in to Bilibili in that browser. - `AD_PLAYING`: a YouTube ad kept playing. Wait for it to end and grab the frame again. -- `PERSONAL_DATA_OFF`: 允许 AI 工具读我的收藏、关注和历史 is off. -- `COLLECTION_WRITES_OFF`: 允许 AI 工具改我的收藏 is off. -- `TRIAGE_WRITES_OFF`: 允许 AI 工具改分拣台 is off. +- `PERSONAL_DATA_OFF`: 允许 Agent 读我的收藏、关注和历史 is off. +- `COLLECTION_WRITES_OFF`: 允许 Agent 改我的收藏 is off. +- `TRIAGE_WRITES_OFF`: 允许 Agent 改分拣台 is off. - `BAD_PARAMS`: a parameter is wrong, the error says which; nothing was written. - `PROPOSALS_FULL`: 20 proposals already wait for you in the 分拣台. - `NOT_LOGGED_IN`: that browser is not logged in to Bilibili (for the collection tools: YouTube). From ba1e32f8f776cb59828c9a54aa85335341822b4c Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:59:51 +0800 Subject: [PATCH 32/37] =?UTF-8?q?MCP=20=E6=94=B9=E6=94=B6=E8=97=8F?= =?UTF-8?q?=EF=BC=9A=E5=8A=A0=20B=E7=AB=99=E7=A8=8D=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E7=9C=8B=EF=BC=88list=5Fcollection=20/=20collection=5Fwrite?= =?UTF-8?q?=EF=BC=8Csite=20bilibili=EF=BC=8Ckind=20watch=5Flater=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E8=B5=B0=E5=88=86=E6=8B=A3=E5=8F=B0=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E7=BB=84=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/AGENT.md | 4 +- extension/agent-sites/bilibili.js | 83 ++++++++++++++++------ extension/agent-sites/bilibili.selftest.js | 45 +++++++++++- extension/options.html | 2 +- mcp/README.md | 12 ++-- mcp/server.js | 12 ++-- 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 50126116..459c2850 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ ## 给 AI agent 用 -Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能借你浏览器的登录状态拿到视频的字幕、章节、简介和热门评论,还能截取任意一秒的画面。用 MCP 时,agent 还能借你的登录搜 B站和 YouTube 视频。不下载视频,不花 AI 额度,读不到你的 Key 和 AI 对话。你的收藏夹、关注、观看历史和分拣台里的标签、备注,只有打开「允许 Agent 读我的收藏、关注和历史」后才能通过 MCP 读;YouTube 稍后观看和 YouTube 播放列表,再打开「允许 Agent 改我的收藏」后 agent 才能改。两种接法: +Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能借你浏览器的登录状态拿到视频的字幕、章节、简介和热门评论,还能截取任意一秒的画面。用 MCP 时,agent 还能借你的登录搜 B站和 YouTube 视频。不下载视频,不花 AI 额度,读不到你的 Key 和 AI 对话。你的收藏夹、关注、观看历史和分拣台里的标签、备注,只有打开「允许 Agent 读我的收藏、关注和历史」后才能通过 MCP 读;B站稍后再看、YouTube 稍后观看和 YouTube 播放列表,再打开「允许 Agent 改我的收藏」后 agent 才能改。两种接法: - **skill**(agent 能操控浏览器,如 ego、Claude in Chrome、Playwright): diff --git a/docs/AGENT.md b/docs/AGENT.md index f41cc25e..bfa38fe8 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -87,10 +87,10 @@ A site whose adapter reads the page itself (§5, `read` a function) is read only - `follows`: `{ source, total, page, hasMore, items: [{ mid, name, sign, groups, tags? }] }`, 50 a page. With a synced 关注 list (the 分拣台's 关注) it is read from there (`source: "local"`, with the user's UP 标签 as `tags`, and `query` matched on name, sign, groups and tags); else one Bilibili page of the followings plus the group names (`source: "bilibili"`, `query` is not applied and `queryIgnored: true`). `groups` are 关注分组 names, 特别关注 and 默认分组 included. - `history`: `{ items: [{ bvid?, url, title, author, viewAt, progress, duration, kind }], cursor }`, 30 a call, newest first. `cursor` is opaque; pass it back for the next page, `""` at the end. `kind` is Bilibili's business (`archive` for a video, also `pgc`, `live`, `article`…); `progress` is seconds, -1 finished; `viewAt` seconds since the epoch. -`collection` and `collectionWrite` (`extension/agent-collections.js`) work on any site whose adapter has `collections` (§5): YouTube today. `kind` is one of the adapter's kinds, else `UNSUPPORTED_KIND`; YouTube has `watch_later`, `playlists`, `playlist` (with `id`), `likes`, `subscriptions`, `history`, and writes to `watch_later` and `playlist` only (`add`, `remove`). Calls run one at a time, a second apart, and stop with `TIMEOUT` while still queued at the deadline. +`collection` and `collectionWrite` (`extension/agent-collections.js`) work on any site whose adapter has `collections` (§5): Bilibili and YouTube. `kind` is one of the adapter's kinds, else `UNSUPPORTED_KIND`; Bilibili has `watch_later` (稍后再看, `add` and `remove`; its favorites, follows and history stay in `favorites`, `follows`, `history`), through `triage/triage-bg.js`'s own 稍后再看 requests in the service worker; YouTube has `watch_later`, `playlists`, `playlist` (with `id`), `likes`, `subscriptions`, `history`, and writes to `watch_later` and `playlist` only (`add`, `remove`). Calls run one at a time, a second apart, and stop with `TIMEOUT` while still queued at the deadline. - `collection`: while 允许 Agent 读我的收藏、关注和历史 is off, `PERSONAL_DATA_OFF`. `data`: `{ site, kind, id?, items, cursor }`, `cursor` `""` at the end, else passed back for the next page. A video `{ videoId, url, title, author, duration, unavailable? }` (`duration` 0 when unknown), a playlist `{ id, title, count? }`, a channel `{ channelId, url, title }`. -- `collectionWrite`: `items` 1 to 20 distinct ids (YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 Agent 改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. +- `collectionWrite`: `items` 1 to 20 distinct ids (Bilibili: BV ids; YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 Agent 改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. - YouTube: youtubei calls (`browse` with browseId `VLWL`, `VL`, `VLLL`, `FEplaylist_aggregation`, `FEchannels`, `FEhistory`, and `continuation` for later pages; `playlist/get_add_to_playlist` for an item's state, the Save dialog's list of the user's own playlists; `browse/edit_playlist` with `ACTION_ADD_VIDEO` / `ACTION_REMOVE_VIDEO_BY_VIDEO_ID`) made in the bridge's own tab on a youtube.com page (any YouTube page it already shows, else `https://www.youtube.com/`), in the page's MAIN world, with the page's `ytcfg` context and the `SAPISIDHASH` authorization yt-dlp computes (`_get_sid_authorization_header`). Never in the user's tabs. A service worker fetch cannot make these calls: it carries the extension's `Origin`, which fetch cannot change. The endpoint and field names come from yt-dlp and public client code; they are tested against hand-written answers only, not against YouTube. `triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → MCP → 允许 Agent 改分拣台 (`mcpWrites`, under the read switch and greyed out while it or 允许 MCP 连接 is off) is off they fail with `TRIAGE_WRITES_OFF`, and while the read switch is off with `PERSONAL_DATA_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). diff --git a/extension/agent-sites/bilibili.js b/extension/agent-sites/bilibili.js index b9da4446..bff9f157 100644 --- a/extension/agent-sites/bilibili.js +++ b/extension/agent-sites/bilibili.js @@ -1,22 +1,63 @@ // Bilibili for the agent interface (docs/AGENT.md §5). The content script (agent-page.js) reads its pages; its search -// stays in agent-search.js with YouTube's, which share the text helpers. -MdgAgentSites.register({ - id: "bilibili", - origins: ["*://*.bilibili.com/*"], - key(u) { - if (u.hostname.replace(/^www\./, "") !== "bilibili.com") return null; - const q = u.searchParams; - let m = /^\/video\/((?:BV|bv)[0-9A-Za-z]+|av\d+)\/?$/.exec(u.pathname); - if (m) return `${m[1]}:${q.get("p") || "1"}`; - m = /^\/bangumi\/play\/((?:ep|ss)\d+)\/?$/.exec(u.pathname); - if (m) return `${m[1]}:`; - // These pages play q.bvid; without it the page itself is the only identity. - if (/^\/(list|festival)\/[^/]+/.test(u.pathname)) return `${q.get("bvid") || u.pathname}:${q.get("p") || "1"}`; - return null; - }, - // m.bilibili.com and bilibili.com: the same path on www, where the content script runs. b23.tv short links are not - // followed (the id is behind a redirect). - canonical: (u) => (/^(m\.)?bilibili\.com$/.test(u.hostname) ? `https://www.bilibili.com${u.pathname}${u.search}` : null), - read: "content", - search: (query, page) => MdgAgentSearch.searchBili(query, page) -}); +// stays in agent-search.js with YouTube's, which share the text helpers. 稍后再看 goes through the 分拣台's own requests +// (triage/triage-bg.js, loaded later in the service worker: looked up when called): its cookies, pacing and risk codes. +(() => { + const PAGE = 20; + const BVID = /^BV[0-9A-Za-z]{10}$/; + const fail = (message) => Object.assign(new Error(message), { code: "BAD_PARAMS" }); + const toview = async () => (await triageToviewList()).map(triageToviewItem); + const bvidOf = (item) => { + if (!BVID.test(item)) throw fail(`${item} is not a BV id.`); + return item; + }; + + // The whole list is one request; the agent gets it PAGE at a time, the cursor being the offset. + async function list({ cursor }) { + if (cursor && !/^\d+$/.test(cursor)) throw fail("cursor must be the cursor a previous list_collection returned."); + const all = await toview(); + const from = Number(cursor || 0); + const items = all.slice(from, from + PAGE).map((v) => ({ + videoId: v.bvid, url: `https://www.bilibili.com/video/${v.bvid}`, title: v.title, author: v.upper, duration: v.duration || 0, + ...(v.invalid && { unavailable: true }) + })); + return { items, cursor: from + PAGE < all.length ? String(from + PAGE) : "" }; + } + + const has = async ({ item }) => (await toview()).some((v) => v.bvid === bvidOf(item)); + + // 稍后再看 takes the aid: from the list for a removal, from the video's own page data for an add. + async function write({ op, item }) { + const bvid = bvidOf(item); + const aid = op === "remove" + ? (await toview()).find((v) => v.bvid === bvid)?.aid + : (await triageBiliGet(`${TRIAGE_BILI_API}/x/web-interface/view?bvid=${bvid}`))?.aid; + if (!aid) throw fail(`${bvid} was not found on Bilibili.`); + await triageBiliPost(`/x/v2/history/toview/${op === "add" ? "add" : "del"}`, { aid }); + } + + MdgAgentSites.register({ + id: "bilibili", + origins: ["*://*.bilibili.com/*"], + key(u) { + if (u.hostname.replace(/^www\./, "") !== "bilibili.com") return null; + const q = u.searchParams; + let m = /^\/video\/((?:BV|bv)[0-9A-Za-z]+|av\d+)\/?$/.exec(u.pathname); + if (m) return `${m[1]}:${q.get("p") || "1"}`; + m = /^\/bangumi\/play\/((?:ep|ss)\d+)\/?$/.exec(u.pathname); + if (m) return `${m[1]}:`; + // These pages play q.bvid; without it the page itself is the only identity. + if (/^\/(list|festival)\/[^/]+/.test(u.pathname)) return `${q.get("bvid") || u.pathname}:${q.get("p") || "1"}`; + return null; + }, + // m.bilibili.com and bilibili.com: the same path on www, where the content script runs. b23.tv short links are not + // followed (the id is behind a redirect). + canonical: (u) => (/^(m\.)?bilibili\.com$/.test(u.hostname) ? `https://www.bilibili.com${u.pathname}${u.search}` : null), + read: "content", + search: (query, page) => MdgAgentSearch.searchBili(query, page), + // Favorites, follows and history stay in agent-mydata.js (get_favorites…); here only what the agent may change. + collections: { watch_later: ["add", "remove"] }, + list, + has, + write + }); +})(); diff --git a/extension/agent-sites/bilibili.selftest.js b/extension/agent-sites/bilibili.selftest.js index 473a7d56..156548bd 100644 --- a/extension/agent-sites/bilibili.selftest.js +++ b/extension/agent-sites/bilibili.selftest.js @@ -22,4 +22,47 @@ assert.strictEqual(S.canonical("https://m.bilibili.com/video/BV1xx411c7mD?p=2"), assert.strictEqual(S.canonical("https://bilibili.com/video/av170001"), "https://www.bilibili.com/video/av170001"); assert.strictEqual(S.canonical("https://b23.tv/abc"), "https://b23.tv/abc"); assert.strictEqual(S.get("bilibili").read, "content"); -console.log("agent-sites bilibili selftest ok"); + +// 稍后再看 through the 分拣台's requests (triage-bg.js), stubbed here: list pages, has, write by aid. +(async () => { + const fs = require("fs"); + const path = require("path"); + const vm = require("vm"); + const bv = (n) => `BV1toview${String(n).padStart(3, "0")}`; + let list = Array.from({ length: 25 }, (_, i) => ({ bvid: bv(i), aid: 100 + i, title: `t${i}`, upper: "UP", duration: 60, invalid: i === 1 })); + const posts = []; + const gets = []; + const ctx = vm.createContext({ + URL, + TRIAGE_BILI_API: "https://api.bilibili.com", + triageToviewList: async () => list, + triageToviewItem: (m) => m, + triageBiliGet: async (url) => (gets.push(url), { aid: 999 }), + triageBiliPost: async (p, fields) => posts.push([p, fields]) + }); + for (const f of ["registry.js", "bilibili.js"]) vm.runInContext(fs.readFileSync(path.join(__dirname, f), "utf8"), ctx, { filename: f }); + const B = ctx.MdgAgentSites.get("bilibili"); + assert.deepStrictEqual(JSON.parse(JSON.stringify(B.collections)), { watch_later: ["add", "remove"] }); + + const p1 = await B.list({ kind: "watch_later", cursor: "" }); + assert.deepStrictEqual([p1.items.length, p1.cursor, p1.items[0].url, p1.items[1].unavailable, p1.items[0].unavailable], [20, "20", `https://www.bilibili.com/video/${bv(0)}`, true, undefined]); + const p2 = await B.list({ kind: "watch_later", cursor: p1.cursor }); + assert.deepStrictEqual([p2.items.length, p2.cursor], [5, ""]); + await assert.rejects(B.list({ kind: "watch_later", cursor: "x" }), /cursor/); + + assert.strictEqual(await B.has({ kind: "watch_later", item: bv(3) }), true); + assert.strictEqual(await B.has({ kind: "watch_later", item: "BV1notthere0" }), false); + await assert.rejects(B.has({ kind: "watch_later", item: "dQw4w9WgXcQ" }), (e) => e.code === "BAD_PARAMS"); + + // A removal takes the aid from the list (no view request); an add asks the video's page data for it. + await B.write({ kind: "watch_later", op: "remove", item: bv(3) }); + await B.write({ kind: "watch_later", op: "add", item: "BV1notthere0" }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(posts)), [["/x/v2/history/toview/del", { aid: 103 }], ["/x/v2/history/toview/add", { aid: 999 }]]); + assert.deepStrictEqual(gets, ["https://api.bilibili.com/x/web-interface/view?bvid=BV1notthere0"]); + await assert.rejects(B.write({ kind: "watch_later", op: "remove", item: "BV1notthere0" }), /not found/); + assert.strictEqual(posts.length, 2, "nothing sent without an aid"); + console.log("agent-sites bilibili selftest ok"); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/extension/options.html b/extension/options.html index 0ad3adc7..1c14d471 100644 --- a/extension/options.html +++ b/extension/options.html @@ -356,7 +356,7 @@

MCP

-

Agent 还能通过 MCP 把视频加进或移出你的 YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,YouTube 限流就停。要先打开「允许 Agent 读我的收藏、关注和历史」。

+

Agent 还能通过 MCP 把视频加进或移出你的 B站稍后再看、YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,被限流就停。要先打开「允许 Agent 读我的收藏、关注和历史」。

diff --git a/mcp/README.md b/mcp/README.md index a978d46c..a4279f05 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -28,8 +28,8 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,Agent 就是界面: -- `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `youtube`,`kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频(`items` 是视频 id)加进或移出稍后观看(`kind: "watch_later"`)或你自己的播放列表(`kind: "playlist"`, `id`)。赞和订阅只能读。默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 +- `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `bilibili` 时 `kind` 只有 `watch_later`(稍后再看);B站 的收藏夹、关注和历史仍用上面三个工具。`site` 写 `youtube` 时 `kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频加进或移出 B站稍后再看、YouTube 稍后观看(都是 `kind: "watch_later"`)或你自己的 YouTube 播放列表(`kind: "playlist"`, `id`)。`items` 是 BV 号或 YouTube 视频 id。赞和订阅只能读。默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 改分拣台的两个工具,默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改分拣台」,关着时返回 `TRIAGE_WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: @@ -93,7 +93,7 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - `search_videos` 不开标签页:扩展后台带着你的登录状态直接请求 B站的搜索接口或 YouTube 的搜索结果页。一次只搜一个,两次搜索至少隔 2 秒。 - 开关默认关闭。关着的时候扩展不建任何连接。 - 收藏、关注和历史另有一个开关,默认关闭,「允许 MCP 连接」关着时它变灰。打开后只读,不改 B站 和 YouTube,也不改插件存的数据;API Key 和 AI 对话不会给出去。 -- 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 +- 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。B站稍后再看由扩展后台直接改,和分拣台改稍后再看是同一组请求。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 - 改分拣台也另有一个开关,默认关闭,和改收藏一样,上面两个有一个关着它就变灰。打开后 Agent 只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 ### 两个 MCP 客户端同时开着 @@ -155,8 +155,8 @@ These return one page per call and run one at a time, a second apart. Two tools work on your own YouTube collections. MoonDigest has no pages for them: the AI tool is the UI. -- `list_collection({ site, kind, id?, cursor?, raw? })`: `site` is `youtube`; `kind` is `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos (`items`, video ids) to or removes them from Watch later (`kind: "watch_later"`) or one of your own playlists (`kind: "playlist"`, `id`). Likes and subscriptions are read-only. Off by default: turn on 允许 Agent 改我的收藏 under 允许 Agent 读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. +- `list_collection({ site, kind, id?, cursor?, raw? })`: `site` `bilibili` has only `kind` `watch_later` (稍后再看); Bilibili favorites, follows and history stay in the three tools above. `site` `youtube` has `kind` `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos to or removes them from Bilibili 稍后再看 or YouTube Watch later (both `kind: "watch_later"`) or one of your own YouTube playlists (`kind: "playlist"`, `id`). `items` are BV ids or YouTube video ids. Likes and subscriptions are read-only. Off by default: turn on 允许 Agent 改我的收藏 under 允许 Agent 读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. Two tools change the 分拣台. Both are off by default: turn on 允许 Agent 改分拣台 under 允许 Agent 读我的收藏、关注和历史. While it is off they return `TRIAGE_WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. @@ -220,7 +220,7 @@ Then turn on 允许 MCP 连接 in MoonDigest's settings (MCP). - `search_videos` opens no tab: the extension's background requests Bilibili's search API or YouTube's results page directly, with your login. One search at a time, at least 2 s apart. - The switch is off by default. While off, the extension opens no socket. - Favorites, follows and history have their own switch, off by default and greyed out while 允许 MCP 连接 is off. When on, the extension only reads: it changes nothing on Bilibili, YouTube or in its own storage, and never gives out API keys or AI conversations. -- Changing collections has one more switch, off by default and greyed out while either of the two above is off. YouTube reads and writes are sent from the extension's own background tab, in the YouTube page's own context, the same requests the YouTube page sends; your open tabs are not used. +- Changing collections has one more switch, off by default and greyed out while either of the two above is off. Bilibili 稍后再看 is changed by the extension's background directly, with the same requests the 分拣台 uses for it. YouTube reads and writes are sent from the extension's own background tab, in the YouTube page's own context, the same requests the YouTube page sends; your open tabs are not used. - Changing the 分拣台 has its own switch too, off by default and, like changing collections, greyed out while either of the two above is off. When on, an AI tool changes only local 分拣 results directly, each call undoable in the 分拣台; a change on Bilibili it can only propose, and you confirm it in the 分拣台, not through the MCP client's permission prompt. ### Two MCP clients at once diff --git a/mcp/server.js b/mcp/server.js index 27059355..e424e24e 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -332,11 +332,11 @@ const TOOLS = [ }, { name: "list_collection", - description: "Read-only: one page of the user's own collection on a site, through their browser and login. YouTube kinds: watch_later, playlists (the user's playlists: id, title), playlist (the videos of the playlist id), likes, subscriptions (channels), history. Bilibili favorites, follows and history stay in get_favorites, get_follows and get_history. Pass the returned cursor for the next page; read one page, then decide whether you need more. Fails with PERSONAL_DATA_OFF until the user allows it, UNSUPPORTED_KIND for a kind the site does not have.", + description: "Read-only: one page of the user's own collection on a site, through their browser and login. Bilibili kind: watch_later (稍后再看). YouTube kinds: watch_later, playlists (the user's playlists: id, title), playlist (the videos of the playlist id), likes, subscriptions (channels), history. Bilibili favorites, follows and history stay in get_favorites, get_follows and get_history. Pass the returned cursor for the next page; read one page, then decide whether you need more. Fails with PERSONAL_DATA_OFF until the user allows it, UNSUPPORTED_KIND for a kind the site does not have.", inputSchema: { type: "object", properties: { - site: { type: "string", description: "\"youtube\"." }, + site: { type: "string", description: "\"bilibili\" or \"youtube\"." }, kind: { type: "string", enum: ["watch_later", "playlists", "playlist", "likes", "subscriptions", "history"] }, id: { type: "string", description: "The playlist id, for kind playlist." }, cursor: { type: "string", description: "The cursor the previous call returned. Omit for the first page." }, @@ -347,15 +347,15 @@ const TOOLS = [ }, { name: "collection_write", - description: "Changes the user's own collection: adds videos to or removes them from YouTube watch_later or one of the user's own playlists (id). Likes and subscriptions are read-only. Unless the user named exactly these videos and this change, call with dryRun: true first and show the user what would change. At most 20 video ids a call; they are changed one at a time, a few seconds apart, each read before (already so: skipped) and after (confirmed). The result lists each item, what was done and not tried, and the undo call. If it says THROTTLED, stop calling MoonDigest and tell the user. Fails with COLLECTION_WRITES_OFF until the user allows changes (dryRun needs only PERSONAL_DATA_OFF lifted).", + description: "Changes the user's own collection: adds videos to or removes them from Bilibili watch_later (稍后再看), YouTube watch_later or one of the user's own YouTube playlists (id). Likes and subscriptions are read-only. Unless the user named exactly these videos and this change, call with dryRun: true first and show the user what would change. At most 20 video ids a call; they are changed one at a time, a few seconds apart, each read before (already so: skipped) and after (confirmed). The result lists each item, what was done and not tried, and the undo call. If it says THROTTLED, stop calling MoonDigest and tell the user. Fails with COLLECTION_WRITES_OFF until the user allows changes (dryRun needs only PERSONAL_DATA_OFF lifted).", inputSchema: { type: "object", properties: { - site: { type: "string", description: "\"youtube\"." }, + site: { type: "string", description: "\"bilibili\" or \"youtube\"." }, op: { type: "string", enum: ["add", "remove"] }, kind: { type: "string", enum: ["watch_later", "playlist"] }, id: { type: "string", description: "The playlist id, for kind playlist (from list_collection kind playlists)." }, - items: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20, description: "Video ids (the v= of a watch URL)." }, + items: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20, description: "Video ids: Bilibili BV ids, YouTube the v= of a watch URL." }, dryRun: { type: "boolean", description: "Only read: each item's current state and whether it would change." }, raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, }, @@ -427,7 +427,7 @@ export const INSTRUCTIONS = [ "Use it whenever a video matters to the answer, not only when the user links one. When you research a topic, look for tutorials or compare sources and your search turns up Bilibili or YouTube videos, do not judge them by title, thumbnail or view count: read the most promising ones with get_video, check that they really cover what the user asked, and say what each one says, citing mm:ss timestamps and links that open at that moment. Drop the ones that do not match.", "Read a few candidates, one at a time, not dozens: Bilibili and YouTube rate-limit bursts. If a result says THROTTLED, stop and tell the user. To find videos on Bilibili or YouTube, search with search_videos, then read the best few with get_video.", "When the user asks about their own Bilibili (what they saved, who they follow, what they watched lately), use get_favorites, get_follows and get_history. They are read-only and off until the user allows them (PERSONAL_DATA_OFF: ask the user to turn it on). Read one page at a time and stop once you can answer.", - "For the user's own YouTube (watch later, playlists, likes, subscriptions, history) use list_collection; to add videos to or remove them from watch later or a playlist, collection_write, off until the user allows changes (COLLECTION_WRITES_OFF). Dry-run first unless the user named exactly the change, and keep the undo the result gives.", + "For the user's own YouTube (watch later, playlists, likes, subscriptions, history) use list_collection; to add videos to or remove them from Bilibili 稍后再看, YouTube watch later or a YouTube playlist, collection_write, off until the user allows changes (COLLECTION_WRITES_OFF). Dry-run first unless the user named exactly the change, and keep the undo the result gives.", "When the user asks you to sort their favorites, set_triage writes MoonDigest's 分拣台 results (verdict, 保留, note, tags) and propose_fav_change proposes unfav, move or copy, which the user confirms in the 分拣台. Both are off until the user allows them (TRIAGE_WRITES_OFF).", ].join("\n\n"); From f3cc8cadc390cda8f83d3d33099b764eab41fe5d Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:03:35 +0800 Subject: [PATCH 33/37] =?UTF-8?q?=E4=BF=AE=E8=AF=84=E5=AE=A1=EF=BC=9A?= =?UTF-8?q?=E6=8F=90=E8=AE=AE=E7=9A=84=E5=AD=98=E5=82=A8=E5=86=99=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E4=B8=8D=E6=89=A7=E8=A1=8C=EF=BC=9B=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=86=99=E5=A4=B1=E8=B4=A5=E4=B8=8D=E6=8A=8A=E5=B7=B2?= =?UTF-8?q?=E5=86=99=E7=9A=84=E7=BB=93=E6=9E=9C=E6=8A=A5=E6=88=90=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=88=E8=BF=94=E5=9B=9E=20noUndo=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=90=8D=E8=B6=85=E8=BF=87=2012=20=E5=AD=97?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=8B=92=E7=BB=9D=EF=BC=9B=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E7=AB=99=E7=82=B9=E5=86=8D=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=86=85=E8=AF=BB=E5=8F=96=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 16 ++--- extension/agent-bridge.js | 46 ++---------- extension/agent-bridge.selftest.js | 84 ++-------------------- extension/agent-page.js | 3 +- extension/agent-page.selftest.js | 3 - extension/agent-sites/registry.js | 2 +- extension/agent-sites/registry.selftest.js | 7 -- extension/agent-writes.js | 8 ++- extension/agent-writes.selftest.js | 16 +++++ extension/triage/fav-writes.js | 2 +- mcp/server.js | 3 +- 11 files changed, 44 insertions(+), 146 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index bfa38fe8..701c5764 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -75,8 +75,6 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: `get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. -A site whose adapter reads the page itself (§5, `read` a function) is read only in the extension's own tab: the user's tabs are never used. It fails with `NO_ACCESS` until the user has allowed the site's origins. Its `get` `data` is what `read` returned, with `site` set to the site's id. Its `frame` injects `agent-page.js` into the tab's isolated world and runs the same frame as above (`MdgAgentPage.frame`). - `search` opens no tab: the service worker fetches with the user's cookies. Bilibili: the WBI-signed `api.bilibili.com/x/web-interface/wbi/search/type?search_type=video` through the same channel as 分拣台 (`triageBiliGet`). YouTube: the `www.youtube.com/results?search_query=…` page, read from its `ytInitialData`; only the result list's own videos, no shorts, ads, channels, playlists or shelves. YouTube has page 1 only (about 20 videos); a `page` above 1 is an error. Searches run one at a time, at least 2 s apart; one still queued at the call's deadline is dropped with `TIMEOUT`. HTTP 412, B站's risk-control codes and YouTube's 429 are `THROTTLED`. No results is `items: []`, not an error. `data`: `{ site, query, page, items }`, each item `{ site, videoId, url, title, author, duration, views, uploadDate, description }`: `url` is `https://www.bilibili.com/video/BV…` or `https://www.youtube.com/watch?v=…` (ready for `get`); `title`, `author`, `description` plain text (B站's `` highlights and HTML entities removed), `description` at most 200 characters; `duration` seconds, `0` when unknown (a live stream); `views` a number or `null`; `uploadDate` B站 `YYYY-MM-DD` (local day), YouTube as the page writes it ("2 years ago", in the browser's language), `""` when missing. @@ -112,7 +110,7 @@ Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answ ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (the user has not allowed MoonDigest on that site, §5), `TOO_LARGE` (a page-run `read` returned more than 300,000 characters of JSON, §5), `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → MCP → 允许 Agent 读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 Agent 改分拣台), `BAD_PARAMS` (a parameter is wrong, the error says which; nothing was asked or written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (the site did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → MCP → 允许 Agent 改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `NO_ACCESS` (MoonDigest has no access to that site, §5), `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → MCP → 允许 Agent 读我的收藏、关注和历史), `TRIAGE_WRITES_OFF` (the same for `triage` / `propose`: 允许 Agent 改分拣台), `BAD_PARAMS` (a parameter is wrong, the error says which; nothing was asked or written), `PROPOSALS_FULL` (20 proposals wait for the user), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (the site did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits), `COLLECTION_WRITES_OFF` (the user has not allowed `collectionWrite`: ask them to turn on 设置 → MCP → 允许 Agent 改我的收藏), `UNSUPPORTED_KIND` (the site has no such collection, or it is read only). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. @@ -128,11 +126,7 @@ MdgAgentSites.register({ const m = /^\/v\/(\w+)$/.exec(u.pathname); return u.hostname === "www.demo.com" && m ? `${m[1]}:` : null; }, - read: (url) => { // "content", or a function run in the page's MAIN world (see below) - const s = window.__INITIAL_STATE__; - return s ? { url, title: s.title, author: s.author, duration: s.duration, description: s.desc, - chapters: [], subtitle: { segments: [], lang: "", warning: "", missing: "no subtitle" }, comments: [] } : null; - }, + read: "content", // the content script (agent-page.js) answers in the page search: async (query, page) => [], // optional: §2's search items, throw an Error with code THROTTLED when rate-limited // optional, all four together: the user's collections (§2 collection / collectionWrite) collections: { saved: ["add", "remove"], history: [] }, // kind → the writes it takes ([] read only) @@ -144,8 +138,6 @@ MdgAgentSites.register({ - `canonical(u)` (optional): the page to load for another form of the site's URL (YouTube: `youtu.be`, `/shorts/`, `/live/`, `m.` and `music.youtube.com` → `www.youtube.com/watch?v=…`; Bilibili: `m.bilibili.com` and `bilibili.com` → `www.bilibili.com`, same path; `b23.tv` short links are not followed), else null. The bridge applies it to the requested `url` only, never to tabs. - `key`: the registry prefixes the id, so two URLs of one video must give the same `itemId:part`. Two keys equal means the same video for tab reuse. -- `read: "content"`: a content script (`agent-page.js`) answers in the page; Bilibili and YouTube only, it needs a manifest entry. -- `read` a function: `chrome.scripting.executeScript({ world: "MAIN", func: read, args: [url] })` in the bridge's own tab. It is serialized: no closures, no extension globals. It returns §1's `get` data (missing fields read as unknown), `{ error, code? }` when the site refuses (the call's error, not asked again; `code` passes only as `THROTTLED`, for risk control; `error` stops at 500 characters), or a falsy value while the page is not ready, and is asked again every 0.5 s for up to 50 s. A throw counts as not ready. One that does not settle by the call's deadline is `TIMEOUT`, and the own tab is sent to `about:blank` so nothing of the page keeps running. An answer over 300,000 characters of JSON fails with `TOO_LARGE`; its `comments` go through `MdgSites.cleanComment` (500 characters, 匿名 for no name), so `read` passes the page's comments on as they are. Write it as an arrow (`read: async (url) => {…}`), never the method shorthand `read(url) {…}`: Chrome runs `(` + its source + `)`, which a method's source is not; `registry.selftest.js` compiles every site's `read` that way. -- Host access: the manifest grants only Bilibili and YouTube; other sites are in `optional_host_permissions` (every http(s) site), so a new site needs no manifest change. Until the user allows its origins the site answers `NO_ACCESS`. Today the only way to allow them is the side panel's grant for reading web pages, which allows every site at once; a per-site 允许 in 设置 is not built yet. -- `list`, `has`, `write`: agent-collections.js has checked the switches, the kind, the op and the items before; the adapter writes one item per `write` and never batches, retries or skips (the rules in §2 are agent-collections.js's). Throw an Error with code `THROTTLED` or `NOT_LOGGED_IN` to stop a batch. To run in the site's page, `MdgAgentBridge.inSitePage(site, url, ready, func, args, deadline)` runs `func(...args)` once in the MAIN world of the bridge's own tab on a page of `site` (loading `url` there when it shows another site), after `ready()` there returns `{ ok: true }`; both are serialized like `read`. `func` runs once even when it fails, so it should catch and return its errors. +- `read: "content"`: a content script (`agent-page.js`) answers in the page. It needs a manifest entry and host access, which the manifest grants for Bilibili and YouTube. +- `list`, `has`, `write`: agent-collections.js has checked the switches, the kind, the op and the items before; the adapter writes one item per `write` and never batches, retries or skips (the rules in §2 are agent-collections.js's). Throw an Error with code `THROTTLED` or `NOT_LOGGED_IN` to stop a batch. To run in the site's page, `MdgAgentBridge.inSitePage(site, url, ready, func, args, deadline)` runs `func(...args)` once in the MAIN world of the bridge's own tab on a page of `site` (loading `url` there when it shows another site), after `ready()` there returns `{ ok: true }`. Both are serialized (`chrome.scripting.executeScript`): no closures, no extension globals; write them as arrows, since a method shorthand's source does not compile there. One that does not settle by the deadline is `TIMEOUT`, and the own tab goes to `about:blank`. `func` runs once even when it fails, so it should catch and return its errors. - A new site adds: `agent-sites/.js`, `agent-sites/.selftest.js` (`require("./registry.selftest.js").load(".js")` gives a registry with only that site loaded), one `importScripts("agent-sites/.js")` line in `background.js`, and its name in the MCP tools' descriptions (`mcp/server.js`). A `search` that reads `MdgErrors.CODE` also needs a line in `entry-matrix.selftest.js`'s `NOT_ENTRIES`. diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 840690d8..90863255 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -2,7 +2,7 @@ // Call MdgAgentBridge.start(...) synchronously at the top level of the service worker so the // chrome.alarms listener is registered before Chrome delivers a wake-up alarm. // Needs the "alarms", "storage", "tabs" and "scripting" permissions (storage.session: Chrome 102+) plus host access to -// each site's origins (agent-sites/): the manifest grants bilibili.com and youtube.com, the user allows the others. +// each site's origins (agent-sites/): the manifest grants bilibili.com and youtube.com. (() => { const SERVER_URL = "ws://127.0.0.1:47823/"; const ALARM = "agent-bridge"; @@ -11,9 +11,6 @@ const PAGE_WAIT_MS = 50_000; const CALL_BUDGET_MS = 55_000; // a call ends before the server's 60 s TIMEOUT, so the server gets its error const PAGE_RETRY_MS = 500; - // What a page-run read may return, as JSON characters: room for long subtitles with their timings. The page is untrusted, so anything larger is refused, not passed on to the agent. - const MAX_READ_CHARS = 300_000; - const MAX_ERROR_CHARS = 500; // a page read's error sentence // The MCP switches the agent interface's methods check (docs/AGENT.md §4): each one's code and sentence. const MCP_SWITCHES = { @@ -158,15 +155,15 @@ // Chrome loads no media in a tab that has never been shown, so a frame there fails with TAB_HIDDEN. Show the tab for // that one frame, then give its window back the tab that was in front. Once loaded, later frames need no switch. - async function callShown(target, method, params, deadline, ask) { - const reply = await callPage(target, method, params, deadline, ask); + async function callShown(target, method, params, deadline) { + const reply = await callPage(target, method, params, deadline); if (method !== "frame" || reply.code !== "TAB_HIDDEN") return reply; const tab = await chrome.tabs.get(target.tabId).catch(() => null); if (!tab) return reply; const [front] = await chrome.tabs.query({ active: true, windowId: tab.windowId }); await chrome.tabs.update(tab.id, { active: true }); try { - return await callPage(target, method, params, deadline, ask); + return await callPage(target, method, params, deadline); } finally { // Give the front back only if ours is still in front: the user may have switched tabs meanwhile. const now = await chrome.tabs.get(tab.id).catch(() => null); @@ -174,27 +171,6 @@ } } - // A site whose adapter reads the page itself: read(url) runs in the page's own world (its globals in reach) and returns - // §1's get data, { error, code? } when the site refuses (asked no more), or a falsy value while the page is not ready - // (callPage asks again). Its comments go through MdgSites.cleanComment here, like every other reader's. - const readInPage = (site, url) => async (tabId) => { - const [r] = await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: site.read, args: [url] }); - const d = r?.result; - if (!d) return undefined; - if (JSON.stringify(d).length > MAX_READ_CHARS) return { ok: false, code: "TOO_LARGE", error: `The page returned more than ${MAX_READ_CHARS} characters.` }; - // The page could claim any code (PERSONAL_DATA_OFF, NOT_CONNECTED…) to steer the agent: only THROTTLED passes. - if (typeof d.error === "string") return { ok: false, error: d.error.slice(0, MAX_ERROR_CHARS), ...(d.code === "THROTTLED" ? { code: d.code } : {}) }; - const comments = Array.isArray(d.comments) ? d.comments.map(MdgSites.cleanComment).filter((c) => c.message) : []; - return { ok: true, data: { ...d, comments, site: site.id } }; - }; - - // A frame there: agent-page.js's frame, injected into the page's isolated world (it stays loaded for the next frame). - const frameInPage = (params) => async (tabId) => { - await chrome.scripting.executeScript({ target: { tabId }, files: ["agent-page.js"] }); - const [r] = await chrome.scripting.executeScript({ target: { tabId }, func: (p) => MdgAgentPage.frame(p), args: [params] }); - return r?.result; - }; - // deadline: when the server stops waiting for this call (its 60 s TIMEOUT). A call still queued then is dropped, so // nothing loads a video or asks the site for a call nobody waits for any more. // Methods other files add (agent-mydata.js): each takes params and the deadline and resolves to a reply, without a tab. @@ -210,20 +186,12 @@ if (!key) return { ok: false, code: "BAD_URL", error: `Not a page MoonDigest reads. Sites: ${MdgAgentSites.list().map((s) => s.id).join(", ")}.` }; const site = MdgAgentSites.get(key.split(":")[0]); const { url: _drop, ...rest } = params; - if (site.read === "content") { - const user = await findUserTab(site, key); - if (user) return callShown(user, method, rest, deadline); - } else { - // Only in the own tab: nothing runs in the user's tabs. - if (!(await chrome.permissions.contains({ origins: site.origins }))) { - return { ok: false, code: "NO_ACCESS", error: `MoonDigest has no access to ${site.origins.join(", ")}: ask the user to allow it.` }; - } - } + const user = await findUserTab(site, key); + if (user) return callShown(user, method, rest, deadline); return inOwnTab(async () => { if (Date.now() > deadline) return { ok: false, code: "TIMEOUT", error: "The call waited too long behind earlier ones." }; const own = await ownTabFor(key, url); - const ask = site.read === "content" ? undefined : method === "get" ? readInPage(site, url) : frameInPage(rest); - const reply = await callShown(own, method, rest, deadline, ask); + const reply = await callShown(own, method, rest, deadline); if (!own.pageKey && reply.ok) { const tab = await chrome.tabs.get(own.tabId).catch(() => null); own.pageKey = tab ? MdgAgentSites.keyOf(tab.url || "") : null; diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 9fd5149d..e6d37853 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -425,64 +425,13 @@ check("a call still queued when the server has given up is dropped", async () => assert.equal(env.updated.length, 0); }); -// A site whose adapter reads the page itself (read is a function run in the page's MAIN world). +// A site for inSitePage's tests. const DEMO = "https://www.demo.test/v/1"; const demoSite = (env) => env.sites.register({ id: "demo", origins: ["https://www.demo.test/*"], key: (u) => (u.hostname === "www.demo.test" && u.pathname.startsWith("/v/") ? `${u.pathname.slice(3)}:` : null), - read: () => globalThis.__STATE__, -}); - -check("a page-read site without access: NO_ACCESS for get and frame, no tab, nothing injected", async () => { - const env = makeEnv({ enabled: true }); - demoSite(env); - const s = await openSocket(env); - assert.deepEqual(await rpc(s, "1", "get", { url: DEMO }), { id: "1", ok: false, error: "MoonDigest has no access to https://www.demo.test/*: ask the user to allow it.", code: "NO_ACCESS" }); - assert.equal((await rpc(s, "2", "frame", { url: DEMO, at: 1 })).code, "NO_ACCESS"); - assert.match((await rpc(s, "3", "get", { url: "https://example.com/" })).error, /Sites: bilibili, youtube, demo\./); - assert.equal(env.created.length, 0); - assert.equal(env.injected.length, 0); -}); - -check("a page-read site runs read in its own tab's MAIN world, never the user's tab, until the page is ready", async () => { - const raw = { uname: " ", like: "x", message: ` ${"字".repeat(600)} ` }; - const replies = [null, { title: "t", site: "spoofed", comments: [raw, { uname: "a", message: " " }] }]; - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], tabs: [{ id: 7, url: DEMO }], inject: () => replies.shift() }); - demoSite(env); - const s = await openSocket(env); - const r = await call(env, s, "1", DEMO); - // Comments come out as MdgSites.cleanComment gives them (匿名, 500 characters); an empty one is dropped. - assert.deepEqual(r, { id: "1", ok: true, data: { title: "t", site: "demo", comments: [{ uname: "匿名", like: 0, message: "字".repeat(500) }] } }); - assert.equal(env.created.length, 1); - assert.equal(env.created[0].active, false); - assert.deepEqual(env.injected.map((i) => i.tabId), [env.created[0].id, env.created[0].id]); - assert.deepEqual(env.injected[0], { tabId: env.created[0].id, world: "MAIN", args: [DEMO] }); - assert.equal(env.sent.length, 0, "no content-script message"); -}); - -check("a page-read site's frame injects agent-page.js into its own tab and runs its frame there, in front when hidden", async () => { - const replies = [{ ok: false, code: "TAB_HIDDEN", error: "hidden" }, { ok: true, data: { at: 5 } }]; - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: (_tab, d) => (d.files ? undefined : replies.shift()) }); - demoSite(env); - const s = await openSocket(env); - s.receive({ id: "1", method: "frame", params: { url: DEMO, at: 5, maxWidth: 640 } }); - for (let i = 0; i < 3; i++) { await settle(); env.runTimers(); } - await settle(); - assert.deepEqual(s.out.find((m) => m.id === "1"), { id: "1", ok: true, data: { at: 5 } }); - const id = env.created[0].id; - const ask = [{ tabId: id, files: ["agent-page.js"] }, { tabId: id, world: undefined, args: [{ at: 5, maxWidth: 640 }] }]; - assert.deepEqual(env.injected, [...ask, ...ask]); - assert.deepEqual(env.shown, [id]); - assert.equal(env.sent.length, 0, "no content-script message"); -}); - -check("a page-read site's { error, code } is the call's error, asked once", async () => { - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: () => ({ error: "Demo asks for a captcha.", code: "THROTTLED" }) }); - demoSite(env); - const s = await openSocket(env); - assert.deepEqual(await call(env, s, "1", DEMO), { id: "1", ok: false, error: "Demo asks for a captcha.", code: "THROTTLED" }); - assert.equal(env.injected.length, 1); + read: "content", }); // inSitePage: an adapter's collection call (§5) in the own tab on any page of the site. @@ -533,34 +482,15 @@ check("inSitePage: func failing is not run again (it may be a write); no access assert.deepEqual([denied.injected.length, denied.created.length], [0, 0]); }); -check("a page-read site's answer over 300,000 JSON characters is TOO_LARGE, asked once", async () => { - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: () => ({ title: "x".repeat(300_000) }) }); - demoSite(env); - const s = await openSocket(env); - const r = await call(env, s, "1", DEMO); - assert.equal(r.code, "TOO_LARGE"); - assert.equal(r.ok, false); - assert.equal(env.injected.length, 1); -}); - -check("a page-read site's read that never settles is TIMEOUT by the deadline, and the own tab leaves the page", async () => { - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: () => new Promise(() => {}) }); - demoSite(env); - const s = await openSocket(env); - const r = await call(env, s, "1", DEMO); +check("inSitePage: a func that never settles is TIMEOUT by the deadline, and the own tab leaves the page", async () => { + const env = sitePage({ inject: () => new Promise(() => {}) }); + const p = env.inSitePage(env.sites.get("demo"), DEMO_HOME, ready, func, ["x"], Date.now() + 1000); + for (let i = 0; i < 5; i++) { await settle(); env.runTimers(); } + const r = await p; assert.deepEqual([r.ok, r.code], [false, "TIMEOUT"]); assert.deepEqual(env.updated.at(-1), { id: env.created[0].id, url: "about:blank" }); - // The queue is free again: the next call runs. - env.tabs[0].url = DEMO; - assert.ok(await call(env, s, "2", DEMO), "the next call answers"); }); -check("a page-read site's error: only THROTTLED passes as a code, the text stops at 500 characters", async () => { - const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], inject: () => ({ error: "x".repeat(600), code: "PERSONAL_DATA_OFF" }) }); - demoSite(env); - const s = await openSocket(env); - assert.deepEqual(await call(env, s, "1", DEMO), { id: "1", ok: false, error: "x".repeat(500) }); -}); (async () => { let failed = 0; diff --git a/extension/agent-page.js b/extension/agent-page.js index 1a7f6647..9e26663d 100644 --- a/extension/agent-page.js +++ b/extension/agent-page.js @@ -4,7 +4,6 @@ // MdgAgentPage.bind({ read, signal }) // read() → §1's get data for the page's current video, or rejects with an Error carrying code. // signal → an AbortSignal that removes the listeners (optional). -// MdgAgentPage.frame(params) → the frame reply, for the bridge to run in a tab with no content script (agent-sites/web.js). (() => { if (globalThis.MdgAgentPage) { return; @@ -150,5 +149,5 @@ signal?.addEventListener("abort", () => chrome.runtime.onMessage.removeListener(onRuntime), { once: true }); } - globalThis.MdgAgentPage = Object.freeze({ bind, frame: (params) => run({ frame }, "frame", params), VERSION }); + globalThis.MdgAgentPage = Object.freeze({ bind, VERSION }); })(); diff --git a/extension/agent-page.selftest.js b/extension/agent-page.selftest.js index a94264d1..d934b804 100644 --- a/extension/agent-page.selftest.js +++ b/extension/agent-page.selftest.js @@ -200,9 +200,6 @@ const DATA = { url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", site: "youtub let t = setup(); t.ctx.MdgAgentPage.bind({ read: async () => DATA }); assert.strictEqual((await t.call("frame", { at: 1 })).code, "NO_VIDEO"); - // The bridge's frame for a page with no content script (agent-sites/web.js) gets the same reply, without bind. - const reply = await setup().ctx.MdgAgentPage.frame({ at: 1 }); - assert.deepStrictEqual([reply.ok, reply.code], [false, "NO_VIDEO"]); t = setup(); const stuck = fakeVideo({ seeks: false }, t.clock); diff --git a/extension/agent-sites/registry.js b/extension/agent-sites/registry.js index 1ba84f3d..3590a5de 100644 --- a/extension/agent-sites/registry.js +++ b/extension/agent-sites/registry.js @@ -7,7 +7,7 @@ const { id, origins, key, read } = adapter || {}; // The id prefixes every key, so a ":" in it would split keys wrongly. const bad = !/^[a-z0-9-]+$/.test(id) || sites.has(id) || !Array.isArray(origins) || !origins.length || typeof key !== "function" || - (read !== "content" && typeof read !== "function"); + read !== "content"; if (bad) throw new Error(`MdgAgentSites: bad adapter ${id}`); sites.set(id, adapter); } diff --git a/extension/agent-sites/registry.selftest.js b/extension/agent-sites/registry.selftest.js index 83302d57..f7e273b8 100644 --- a/extension/agent-sites/registry.selftest.js +++ b/extension/agent-sites/registry.selftest.js @@ -32,12 +32,5 @@ if (require.main === module) { assert.strictEqual(S.keyOf("https://demo.test/abc"), "demo:abc:"); assert.strictEqual(S.keyOf("https://other.test/abc"), null); - // Every site's function read compiles the way chrome.scripting.executeScript builds it: "(" + its source + ")". - // Method shorthand (read(url) {}) does not, and fails only in Chrome. - const { runtimes } = require("../page-sources.selftest.js"); - const worker = runtimes.find((r) => r.where === "background.js").files; - const all = load(...worker.filter((f) => f.startsWith("agent-sites/") && f !== "agent-sites/registry.js").map((f) => f.slice("agent-sites/".length))); - const reads = all.list().filter((s) => typeof s.read === "function"); - for (const s of reads) assert.doesNotThrow(() => new vm.Script(`(${s.read.toString()})`), `${s.id}'s read`); console.log("agent-sites registry selftest ok"); } diff --git a/extension/agent-writes.js b/extension/agent-writes.js index e631405f..175e3759 100644 --- a/extension/agent-writes.js +++ b/extension/agent-writes.js @@ -31,7 +31,7 @@ const asWritten = ({ criteria, ...a } = {}) => a; const tagNames = (v, name) => listOf(v ?? [], 10, name).map((n, j) => { - const clean = MdgTagCore.cleanTagName(text(n, 40, `${name}[${j}]`)); + const clean = MdgTagCore.cleanTagName(text(n, MdgTagCore.TAG_NAME_MAX, `${name}[${j}]`)); if (!clean) throw bad(`${name}[${j}] is not a tag name.`); return clean; }); @@ -84,6 +84,7 @@ const made = []; const notMade = []; const changedIds = () => [...new Set(Object.values(changes).flatMap(Object.keys))]; + let noUndo = false; try { const keep = list.filter((it) => "kept" in it); if (keep.length) { @@ -157,10 +158,11 @@ const n = changedIds().length; if (n || made.length) { const entry = { id: MdgTagCore.newTagId("a"), at: now, n, changes, made }; - await MdgLimits.update(K.triageAgentLog, (log = []) => [...log, entry].slice(-MdgLimits.TRIAGE_UNDO_STEPS)); + // A failed journal must not turn landed writes into an error the tool would retry: the reply says no undo. + await MdgLimits.update(K.triageAgentLog, (log = []) => [...log, entry].slice(-MdgLimits.TRIAGE_UNDO_STEPS)).catch(() => (noUndo = true)); } } - return { changed: changedIds().length, ...(notMade.length && { tagsNotMade: [...new Set(notMade)] }) }; + return { changed: changedIds().length, ...(notMade.length && { tagsNotMade: [...new Set(notMade)] }), ...(noUndo && { noUndo }) }; } // U in the 分拣台 on one journaled triage: each value still as the tool left it goes back; one changed since stays diff --git a/extension/agent-writes.selftest.js b/extension/agent-writes.selftest.js index d698aaa9..5bea4c8f 100644 --- a/extension/agent-writes.selftest.js +++ b/extension/agent-writes.selftest.js @@ -113,6 +113,7 @@ check("triage refuses bad input whole, before writing anything", async () => { { items: [{ bvid: A, addTags: ["新"], folderId: "22" }] }, // not ticked { items: [{ bvid: C, addTags: ["新"], folderId: "11" }] }, // not in that folder { items: [{ bvid: A, addTags: ["、"], folderId: "11" }] }, + { items: [{ bvid: A, addTags: ["一二三四五六七八九十一二三"], folderId: "11" }] }, // past the 12 a tag name holds // A good item does not get through beside a bad one. { items: [{ bvid: A, kept: true }, { bvid: B, verdict: "keep" }] } ]; @@ -123,6 +124,21 @@ check("triage refuses bad input whole, before writing anything", async () => { assert.deepStrictEqual(local, before); }); +check("a journal that cannot be written leaves the call ok, with noUndo: the writes landed, so no error to retry on", async () => { + const set = chrome.storage.local.set; + chrome.storage.local.set = async (o) => { + if (K.triageAgentLog in o) throw new Error("quota"); + return set(o); + }; + try { + const r = await M.triage({ items: [{ bvid: A, kept: true }] }); + assert.deepStrictEqual(plain(r), { ok: true, data: { changed: 1, noUndo: true } }); + assert.deepStrictEqual(log(), []); + } finally { + chrome.storage.local.set = set; + } +}); + check("triage writes verdict, 保留, note and tags, journals it, and U puts all of it back", async () => { local[K.triageAnalysis(B)] = { bvid: B, status: "done", verdict: "drop", oneLiner: "旧的细看" }; const r = await M.triage({ items: [ diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 4d338837..7d1b9785 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -526,7 +526,7 @@ function dropProposal(id) { return storeEdit(K.agentProposals, [], edit).then((list) => { if (list) S.agentProposals = list; renderStatus(); - return took; + return Boolean(list) && took; // a failed write took nothing: still stored, it must not run }); } // Back at the head of the queue, when it could not run after all. diff --git a/mcp/server.js b/mcp/server.js index e424e24e..4566646c 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -207,7 +207,8 @@ export function formatFollows(d) { // The 分拣台 writes (docs/AGENT.md §2 triage / propose). export function formatTriage(d) { const notMade = d.tagsNotMade?.length ? ` Not made (the folder is at its tag limit): ${d.tagsNotMade.join(", ")}.` : ""; - return `Saved in the 分拣台: ${d.changed} video(s) changed. The user can undo this call there.${notMade}`; + const undo = d.noUndo ? " It could not be recorded for undo: tell the user." : " The user can undo this call there."; + return `Saved in the 分拣台: ${d.changed} video(s) changed.${undo}${notMade}`; } export function formatProposal(d) { From b6d86068f360674b948b011be33115496f12d221 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:00:39 +0800 Subject: [PATCH 34/37] =?UTF-8?q?=E6=92=A4=E9=94=80=E6=94=B9=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=EF=BC=9A=E5=8F=AA=E6=94=BE=E5=9B=9E=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E8=BF=98=E6=98=AF=E8=BF=99=E4=B8=80=E6=AD=A5=E6=94=B9=E5=AE=8C?= =?UTF-8?q?=E9=82=A3=E6=A0=B7=E7=9A=84=E5=AF=B9=E8=B1=A1=EF=BC=8C=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E5=9C=A8=E5=88=AB=E5=A4=84=E6=94=B9=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E4=B8=8D=E5=8A=A8=EF=BC=8C=E6=8F=90=E7=A4=BA=E3=80=8CN=20?= =?UTF-8?q?=E4=B8=AA=E4=BD=A0=E4=B9=8B=E5=90=8E=E6=94=B9=E8=BF=87=EF=BC=8C?= =?UTF-8?q?=E6=B2=A1=E5=8A=A8=E3=80=8D=EF=BC=88=E5=92=8C=E6=92=A4=E9=94=80?= =?UTF-8?q?=20Agent=20=E7=9A=84=E4=BF=AE=E6=94=B9=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 1 + docs/TRIAGE.md | 2 +- extension/triage/follow.js | 13 +++++++---- extension/triage/follow.selftest.js | 7 ++++++ extension/triage/tag-store.js | 31 +++++++++++++++++++------- extension/triage/tag-store.selftest.js | 25 +++++++++++++++++++++ extension/triage/triage.selftest.js | 3 ++- 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 935f5776..f5eb5ffa 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -201,6 +201,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 要撤销的这一步改了 2 个以上对象时,撤销前先确认,比如批量保留、批量取消收藏、批量重新关注、批量移到或复制到分组(整批算一步)、AI 打标签、选中栏的「标签…」。 - 标签只存在扩展里,不改 B站。 - 点卡片上的标签片去掉一个标签,按 U 撤销后三处都写「已撤销:去掉「X」」(`TriageUi.untagStep`);打标签面板关一次的撤销写「已撤销:标签修改」。 +- 按 U 撤销改标签的一步(打标签面板、点标签片、选中栏的「标签…」、AI 打标签的「应用」)时,只放回标签还是这一步改完那样的对象;这一步之后又被改过的(另一个标签页、B站 页面上的标签、Agent)不动,提示里写「N 个你之后改过,没动」,比如「已撤销:AI 打标签(12 个视频,2 个你之后改过,没动)」。标签顺序不同、或用到的标签已经删了,不算改过。和撤销 Agent 的修改同一个规则。为什么:撤销是退回自己刚才那一步,不该把之后别处的修改一起抹掉。 - UP 标签最多 50 个,和摘读记录的标签一样(`MdgLimits.UP_TAGS`):满了以后关注的「标签管理」、打标签面板和 B站 页面的「+ UP 标签」都不再新建,写「已经有 50 个 UP 标签了,先在分拣台「关注」的「标签管理」里删掉不用的」,已有的名字照样能打。AI 打标签建议的新标签点了「应用」后和收藏夹一样可以超过,之后在「标签管理」里删。 - 删除标签的确认框(「将从 N 个…上去掉」)和「标签管理」里每个标签的个数,算删了会去掉这个标签的全部对象:关注连已取消关注的 UP 主也算,他们的标签还留着、删除时一起去掉。收藏夹、关注、摘读记录一样(`TriageUi.tagUses`)。按 U 撤销 AI 应用时删掉这一步新建、已没人用的标签,「没人用」也一样算已取消关注的 UP 主;删掉的标签(这里删、U 删或另一个标签页删的)同时从标签筛选里去掉,列表不会空着又没有片可点。 - 关注的分组就是 B站 的关注分组,像收藏夹的收藏夹一样在这里管:左栏列出默认分组、★ 特别关注和自己建的分组;选中栏有「移动选中的 N 个到分组…」「复制选中的 N 个到分组…」(像收藏夹的「移动/复制选中的 N 个…」,带数量),和收藏夹的移动 / 复制是同一个弹窗,里面可以新建分组;「分组管理」里新建、改名、删除。特别关注就是一个分组(B站 的 -10),卡片上的 ★ 只是把这一个 UP 主放进或移出它。默认分组和特别关注是 B站 固定的,不能改名或删除;默认分组只能移进去,不能复制进去。默认分组是「不在任何自己建的分组里」,特别关注只是多一个标记,所以从默认分组移到特别关注其实是加进去,人还在默认分组:弹窗和提示都写「加进」,不写「移到」。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 9e34434e..91b8df88 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -13,7 +13,7 @@ ``` - **判断标准**:「✎ 判断标准」按钮,没写时带一个小点。不写时,AI 从收藏夹名和简介推测用途。改了标准以后,按旧标准做的结果可以「按新标准重新粗看 / 细看」。 -- **标签**属于各自的收藏夹,每个收藏夹默认最多 10 个(收藏夹设置里可改,1–50),一个视频可以打多个。标签那一行的「标签管理」里改名、写说明、删除,**✦ AI 打标签** 用一句话让 AI 给一批视频打标签(每次默认最多新建 5 个,收藏夹设置里可改),你检查后再应用。 +- **标签**属于各自的收藏夹,每个收藏夹默认最多 10 个(收藏夹设置里可改,1–50),一个视频可以打多个。标签那一行的「标签管理」里改名、写说明、删除,**✦ AI 打标签** 用一句话让 AI 给一批视频打标签(每次默认最多新建 5 个,收藏夹设置里可改),你检查后再应用。改标签和应用都能按 U 撤销,你之后又改过的视频不动。 - 两层互不影响:打标签不算处理,AI 判断也不改你的标签。 ## 四个步骤 diff --git a/extension/triage/follow.js b/extension/triage/follow.js index a9b8da84..884c309d 100644 --- a/extension/triage/follow.js +++ b/extension/triage/follow.js @@ -231,18 +231,23 @@ async function setGoneTags(mid, change) { derive(); return prev; } -// A chip click on one: the tag comes off its record, one U step puts it back (unless 重新关注 or 清理 took the record). +// A chip click on one: the tag comes off its record, one U step puts it back (unless 重新关注 or 清理 took the record, or +// its tags were changed since: then it is left be, as tag-store.js's steps do). async function untagGone(mid, id, name) { const prev = await setGoneTags(mid, (ids) => ids.filter((x) => x !== id)); if (!prev) return; + const after = prev.filter((x) => x !== id); T.pushUndo({ kind: "mode", tags: true, undo: async () => { - const live = new Set(D.tags.map((t) => t.id)); - if ((await setGoneTags(mid, () => prev.filter((x) => live.has(x)))) === false) return false; + const isLive = (x) => D.tags.some((t) => t.id === x); + let moved = false; + const put = (ids) => (moved = MdgTagStore.sameIds(ids.filter(isLive), after.filter(isLive))) ? prev.filter(isLive) : ids; + const was = await setGoneTags(mid, put); + if (was === false) return false; render(); - return `已撤销:${UI.untagStep(name)}`; + return `已撤销:${UI.untagStep(name)}${was && !moved ? "(你之后改过,没动)" : ""}`; } }); toast(UI.untagged(name)); diff --git a/extension/triage/follow.selftest.js b/extension/triage/follow.selftest.js index a34a061c..182139a8 100644 --- a/extension/triage/follow.selftest.js +++ b/extension/triage/follow.selftest.js @@ -910,6 +910,13 @@ const p0Throttled = () => vm.runInContext("MdgErrors.TEXT.biliThrottled", ctx); await p.settle(); assert.deepStrictEqual(plain(p.store.follow_unfollowed.u3.tagIds), ["t1"], "U puts it back"); assert.ok(p.toasts.includes("已撤销:去掉「科普」"), p.toasts.join("|")); + // D4: tags changed on the record since (another tab) are left be, and U says so. + for (const f of p.$("followMain").L.click) await f({ button: 0, preventDefault() {}, target: { closest: (sel) => (sel === "[data-untag]" ? chip : null) } }); + await p.settle(); + p.store.follow_unfollowed = { u3: { ...p.store.follow_unfollowed.u3, tagIds: ["t1"] } }; + p.key("u"); + await p.settle(); + assert.deepStrictEqual([plain(p.store.follow_unfollowed.u3.tagIds), p.toasts.at(-1)], [["t1"], "已撤销:去掉「科普」(你之后改过,没动)"]); // R23: a failed write is said in red and pushes no U step. const set = p.ctx.chrome.storage.local.set; p.ctx.chrome.storage.local.set = async () => { throw new Error("QUOTA_BYTES quota exceeded"); }; diff --git a/extension/triage/tag-store.js b/extension/triage/tag-store.js index 3fb0957b..7a8aee15 100644 --- a/extension/triage/tag-store.js +++ b/extension/triage/tag-store.js @@ -144,23 +144,31 @@ const fits = (p) => UI.aiProposalOk(p, (id) => Boolean(tagOf(id))); // A tag step: U puts the items of before back (tags deleted since stay off) and deletes the tags it created that - // nothing carries any more, in one write. - function tagStep({ before, created = [], label, ask = null, recentAt = 0 }) { + // nothing carries any more, in one write. after is what the step wrote: an item whose tags differ from it now (changed + // since, in another tab, on a B站 page or by the Agent) is left be and counted, as the Agent's own undo does. + function tagStep({ before, after, created = [], label, ask = null, recentAt = 0 }) { return { tags: true, // dropped when a tag is deleted: it could not bring that one back ask, async undo() { let skipped = 0; + let changed = 0; let gone = []; const ok = await a.write((cur) => { + skipped = changed = 0; const live = new Set(cur.tags.map((t) => t.id)); + const isLive = (id) => live.has(id); const map = { ...cur.map }; for (const [key, ids] of Object.entries(before)) { if (a.restorable && !a.restorable(key)) { skipped++; continue; } - setIds(map, key, ids.filter((id) => live.has(id))); + if (!sameIds((cur.map[key] || []).filter(isLive), after[key].filter(isLive))) { + changed++; + continue; + } + setIds(map, key, ids.filter(isLive)); } // Still used: by an item, or by a carrier the map leaves out (关注's 已取消关注). const used = new Set(Object.values(map).flat()); @@ -171,7 +179,10 @@ for (const id of gone) a.tagGone?.(id); if (recentAt) await a.endRecent(recentAt); (a.undone || a.changed)(Object.keys(before)); - return `已撤销:${label}${skipped ? `(${skipped} 个${a.goneWord},标签没改)` : ""}`; + const why = [skipped && `${skipped} 个${a.goneWord},标签没改`, changed && `${changed} 个你之后改过,没动`].filter(Boolean); + // Into the label's own brackets when it has them: 「AI 打标签(2 个视频,1 个你之后改过,没动)」. + if (!why.length) return `已撤销:${label}`; + return label.endsWith(")") ? `已撤销:${label.slice(0, -1)},${why.join(";")})` : `已撤销:${label}(${why.join(";")})`; } }; } @@ -224,6 +235,7 @@ async function change(list, label = MdgErrors.LABEL.tagEdit) { if (!list.length) return 0; const before = {}; + const after = {}; const ok = await a.write((cur) => { const live = new Set(cur.tags.map((t) => t.id)); const map = { ...cur.map }; @@ -232,13 +244,14 @@ const now = TagPicker.applyChange(was.filter((id) => live.has(id)), c); if (sameIds(was, now)) continue; before[c.key] = [...was]; + after[c.key] = now; setIds(map, c.key, now); } return Object.keys(before).length ? { ...cur, map } : cur; }); const n = Object.keys(before).length; if (!ok || !n) return 0; - steps.push(tagStep({ before, label: n > 1 ? `${n} 个${sp(who)}的标签修改` : label, ask: n > 1 ? UI.undoAsk("tags", n, who) : null })); + steps.push(tagStep({ before, after, label: n > 1 ? `${n} 个${sp(who)}的标签修改` : label, ask: n > 1 ? UI.undoAsk("tags", n, who) : null })); a.changed(); return n; } @@ -323,6 +336,7 @@ let changes = []; let made = { created: [], skipped: [] }; const before = {}; + const wrote = {}; const ok = await a.write((cur) => { made = plan(p.newTags, cur.tags, scope); const ids = new Set(made.tags.map((t) => t.id)); @@ -330,7 +344,8 @@ const map = { ...cur.map }; for (const [key, was, after] of changes) { before[key] = [...was]; - setIds(map, key, after.filter((id) => ids.has(id))); + wrote[key] = after.filter((id) => ids.has(id)); + setIds(map, key, wrote[key]); } return { tags: made.created.length ? made.tags : cur.tags, map: changes.length ? map : cur.map }; }); @@ -339,7 +354,7 @@ const n = changes.length; const at = n ? Date.now() : 0; if (n) await a.markRecent(Object.keys(before), at); - if (n || made.created.length) steps.push(tagStep({ before, created: made.created, label: `AI 打标签(${n} 个${sp(who)})`, ask: n > 1 ? UI.undoAsk("ai", n, who) : null, recentAt: at })); + if (n || made.created.length) steps.push(tagStep({ before, after: wrote, created: made.created, label: `AI 打标签(${n} 个${sp(who)})`, ask: n > 1 ? UI.undoAsk("ai", n, who) : null, recentAt: at })); a.changed(); const skipped = made.skipped.length ? `;${made.skipped.length} 个新标签没有建:${a.full(tagsIn(a.tags(), scope).length, scope)}` : ""; a.say(`${UI.aiApplied(n, who, made.created.length)}${skipped}`); @@ -397,5 +412,5 @@ }; } - globalThis.MdgTagStore = { create, local, follow, marks, pushStep, undoLast }; + globalThis.MdgTagStore = { create, local, follow, marks, pushStep, undoLast, sameIds }; })(); diff --git a/extension/triage/tag-store.selftest.js b/extension/triage/tag-store.selftest.js index 0ac66c1d..6b76ec53 100644 --- a/extension/triage/tag-store.selftest.js +++ b/extension/triage/tag-store.selftest.js @@ -130,6 +130,31 @@ const proposal = (newTags, rows) => ({ newTags: newTags.map((name) => ({ key: na assert.deepStrictEqual([plain(p.map.b), p.said.at(-1)[0]], [[g.id], "已撤销:去掉「g」"]); } + // D4: U leaves an item whose tags changed after the step (another tab, a B站 page, the Agent) and says how many; an + // unchanged one goes back. Order does not count as a change, nor does a tag deleted since. Same for 应用's step. + { + const p = page(); + const [g, s, x] = [await p.store.createTag("g"), await p.store.createTag("s"), await p.store.createTag("x")]; + p.map = { a: [g.id], b: [g.id] }; + await p.store.change([{ key: "a", add: [s.id], remove: [] }, { key: "b", add: [s.id], remove: [] }]); + p.map = { ...p.map, a: [g.id, s.id, x.id] }; // a changed since + await p.store.undo(); + assert.deepStrictEqual([plain(p.map), p.said.at(-1)[0]], [{ a: [g.id, s.id, x.id], b: [g.id] }, "已撤销:2 个视频的标签修改(1 个你之后改过,没动)"]); + await p.store.change([{ key: "b", add: [s.id, x.id], remove: [] }]); + p.map = { ...p.map, b: [x.id, s.id, g.id] }; // reordered only + p.tags = p.tags.filter((t) => t.id !== x.id); // deleted elsewhere: the step stays + await p.store.undo(); + assert.deepStrictEqual([plain(p.map.b), p.said.at(-1)[0]], [[g.id], "已撤销:标签修改"]); + + const q = page(); + const t = await q.store.createTag("t"); + assert.ok(await q.store.apply(proposal(["n"], [{ id: "a", add: ["new:n"], remove: [] }, { id: "b", add: ["new:n"], remove: [] }]))); + const n = q.tags[1].id; + q.map = { ...q.map, b: [n, t.id] }; + await q.store.undo(); + assert.deepStrictEqual(plain([q.map, q.tags.map((x) => x.name), q.said.at(-1)[0]]), [{ b: [n, t.id] }, ["t", "n"], "已撤销:AI 打标签(2 个视频,1 个你之后改过,没动)"]); + } + // Delete: asks with every carrier, the ones only uses() knows too (关注's 已取消关注); takes it off all, drops tag steps. { const steps = []; diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index c659de98..02bdfacd 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -1548,7 +1548,8 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = await t.undo(); vm.runInContext("askConfirm = __realAsk", ctx); assert.ok(!t.S.aiRecent.K && !t.S.aiRecentFilter, "U ends the batch"); - assert.deepStrictEqual(plain([t.S.videoTags.BV700, t.S.videoTags.BV701, t.S.videoTags.BV702]), [null, null, ["ga"]], "U puts every video of the batch back, later edits included"); + assert.deepStrictEqual(plain([t.S.videoTags.BV700, t.S.videoTags.BV701, t.S.videoTags.BV702]), [null, ["gb"], ["ga"]], "D4: U puts the batch back, but not a video edited since"); + assert.strictEqual(toasts.at(-1), "已撤销:AI 打标签(3 个视频,1 个你之后改过,没动)"); assert.ok(!t.S.tags.some((x) => x.name === "C"), "a tag the batch created and nothing uses any more goes"); t.S.aiRecent.K = { at: 1, bvids: ["BV701"] }; t.S.aiRecentFilter = true; From ed5d645e3be2db44fb9735860733e6586133a59d Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:24:29 +0800 Subject: [PATCH 35/37] =?UTF-8?q?MCP=20=E6=94=B9=E6=94=B6=E8=97=8F?= =?UTF-8?q?=EF=BC=9A=E9=A1=B5=E9=9D=A2=E5=86=85=E8=B0=83=E7=94=A8=E6=8C=89?= =?UTF-8?q?=E8=A6=81=E6=89=93=E5=BC=80=E7=9A=84=E9=82=A3=E9=A1=B5=E7=9A=84?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E6=9F=A5=E6=9D=83=E9=99=90=EF=BC=88=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E5=8F=AA=E7=BB=99=E4=BA=86=20www.youtube.com=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=20*.youtube.com=20=E6=9F=A5=E6=B0=B8=E8=BF=9C?= =?UTF-8?q?=E6=98=AF=20NO=5FACCESS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/agent-bridge.js | 9 ++++++--- extension/agent-bridge.selftest.js | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 90863255..fb0a7884 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -96,12 +96,15 @@ const run = (f, a) => async (tabId) => (await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: f, args: a }))[0]?.result; return inOwnTab(async () => { if (Date.now() > deadline) return { ok: false, code: "TIMEOUT", error: "The call waited too long behind earlier ones." }; - if (!(await chrome.permissions.contains({ origins: site.origins }))) { - return { ok: false, code: "NO_ACCESS", error: `MoonDigest has no access to ${site.origins.join(", ")}: ask the user to allow it.` }; + // The page it runs in is url's origin, not every origin of the site (the manifest grants www.youtube.com, not + // *.youtube.com): that origin is the one to have access to, and the one the own tab must already show. + const origin = new URL(url).origin; + if (!(await chrome.permissions.contains({ origins: [`${origin}/*`] }))) { + return { ok: false, code: "NO_ACCESS", error: `MoonDigest has no access to ${origin}: ask the user to allow it.` }; } let own = await loadOwn(); const tab = own && (await chrome.tabs.get(own.tabId).catch(() => null)); - const onSite = tab && (await chrome.tabs.query({ url: site.origins })).some((t) => t.id === tab.id); + const onSite = tab && (tab.url || "").startsWith(`${origin}/`); if (!onSite) own = await openOwn(tab, null, url); const target = { tabId: own.tabId, staleUrl: onSite ? null : own.staleUrl }; const up = await callPage(target, "", null, deadline, run(ready, [])); diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index e6d37853..aee1ea40 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -482,6 +482,17 @@ check("inSitePage: func failing is not run again (it may be a write); no access assert.deepEqual([denied.injected.length, denied.created.length], [0, 0]); }); +check("inSitePage: access is checked for the page it loads, not every origin of the site (YouTube: www only is granted)", async () => { + const env = sitePage({ tabs: [] }); + env.sites.register({ id: "wide", origins: ["*://*.demo.test/*"], key: () => null, read: "content" }); + const r = JSON.parse(JSON.stringify(await (async () => { + const p = env.inSitePage(env.sites.get("wide"), DEMO_HOME, ready, func, ["x"], Date.now() + 60_000); + for (let i = 0; i < 5; i++) { await settle(); env.runTimers(); } + return p; + })())); + assert.equal(r.ok, true, JSON.stringify(r)); +}); + check("inSitePage: a func that never settles is TIMEOUT by the deadline, and the own tab leaves the page", async () => { const env = sitePage({ inject: () => new Promise(() => {}) }); const p = env.inSitePage(env.sites.get("demo"), DEMO_HOME, ready, func, ["x"], Date.now() + 1000); From 4218dd03994c7159639f42bc94c5a5453422c690 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:33:26 +0800 Subject: [PATCH 36/37] =?UTF-8?q?MCP=20=E6=94=B9=E6=94=B6=E8=97=8F?= =?UTF-8?q?=EF=BC=9AYouTube=20=E4=BB=A5=E5=86=99=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=20STATUS=5FSUCCEEDED=20=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=86=99=E5=AE=8C=E7=AB=8B?= =?UTF-8?q?=E5=88=BB=E9=87=8D=E8=AF=BB=EF=BC=88=E5=AE=9E=E6=B5=8B=E5=AE=83?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E8=AF=BB=E8=A6=81=E7=BA=A6=2012=20?= =?UTF-8?q?=E7=A7=92=E6=89=8D=E8=B7=9F=E4=B8=8A=EF=BC=89=EF=BC=9B=E5=86=99?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E6=9D=A1=E7=9B=AE=2030=20=E7=A7=92=E5=86=85?= =?UTF-8?q?=E6=8C=89=E5=88=9A=E5=86=99=E7=9A=84=E7=AE=97=EF=BC=8C=E7=B4=A7?= =?UTF-8?q?=E6=8E=A5=E7=9D=80=E6=92=A4=E9=94=80=E4=B8=8D=E4=BC=9A=E8=A2=AB?= =?UTF-8?q?=E5=BD=93=E6=88=90=E3=80=8C=E5=B7=B2=E6=98=AF=E5=A6=82=E6=AD=A4?= =?UTF-8?q?=E3=80=8D=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENT.md | 2 +- extension/agent-collections.js | 20 ++++++++++++---- extension/agent-collections.selftest.js | 31 ++++++++++++++++++------- extension/agent-sites/youtube.js | 5 +++- mcp/README.md | 4 ++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 701c5764..235ef7c1 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -88,7 +88,7 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: `collection` and `collectionWrite` (`extension/agent-collections.js`) work on any site whose adapter has `collections` (§5): Bilibili and YouTube. `kind` is one of the adapter's kinds, else `UNSUPPORTED_KIND`; Bilibili has `watch_later` (稍后再看, `add` and `remove`; its favorites, follows and history stay in `favorites`, `follows`, `history`), through `triage/triage-bg.js`'s own 稍后再看 requests in the service worker; YouTube has `watch_later`, `playlists`, `playlist` (with `id`), `likes`, `subscriptions`, `history`, and writes to `watch_later` and `playlist` only (`add`, `remove`). Calls run one at a time, a second apart, and stop with `TIMEOUT` while still queued at the deadline. - `collection`: while 允许 Agent 读我的收藏、关注和历史 is off, `PERSONAL_DATA_OFF`. `data`: `{ site, kind, id?, items, cursor }`, `cursor` `""` at the end, else passed back for the next page. A video `{ videoId, url, title, author, duration, unavailable? }` (`duration` 0 when unknown), a playlist `{ id, title, count? }`, a channel `{ channelId, url, title }`. -- `collectionWrite`: `items` 1 to 20 distinct ids (Bilibili: BV ids; YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 Agent 改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. +- `collectionWrite`: `items` 1 to 20 distinct ids (Bilibili: BV ids; YouTube: video ids), else refused before any request. A real run needs 设置 → MCP → 允许 Agent 改我的收藏 (`mcpCollectionWrite`, under the read switch and greyed out while it or 允许 MCP 连接 is off) on as well, else `COLLECTION_WRITES_OFF`; `dryRun: true` needs the read switch only and writes nothing. The items go one at a time, 1.5–3 s apart (random): the item's state is read first and an item already as asked is skipped (`unchanged`); otherwise a dry run reports `would_change`, a real run writes, waits 1 s and reads again (`done`, or `unconfirmed` when the read still shows the old state). A YouTube write is `done` on YouTube's own answer (`STATUS_SUCCEEDED`, else `failed`) with no read after: its state read lags writes by about 12 s. For 30 s after a write the item counts as written, so an undo right after is not skipped as already so. `THROTTLED` (429, a captcha page) or `NOT_LOGGED_IN` (no login, or 403: the login or the request's signature not accepted) stops the batch; another error fails only its item (`failed`). An item is not started with less than 12 s left before the call's deadline, nor its write sent with less than 22 s left (time for the write and the read that confirms it); each youtubei request ends within 10 s and by the deadline (`stopped: "TIMEOUT"`): call again with `notDone`, which is safe since done items are skipped. `data`: `{ site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }`, `undo` the `collectionWrite` params that reverse every item whose write went out (`done`, `unconfirmed`, and `failed` after the write was sent, which may have landed; undo reads each first and skips the ones that had not), or `null`. Undo is not exact: an item removed and added back goes to the end of the playlist (YouTube adds at the end), and a video a playlist held more than once is removed by its id and added back once. No confirmation token between a dry run and the run: the agent makes both calls, so a token would check nothing a dry run does not; the switches, the cap, the skip and the undo are the safety. - YouTube: youtubei calls (`browse` with browseId `VLWL`, `VL`, `VLLL`, `FEplaylist_aggregation`, `FEchannels`, `FEhistory`, and `continuation` for later pages; `playlist/get_add_to_playlist` for an item's state, the Save dialog's list of the user's own playlists; `browse/edit_playlist` with `ACTION_ADD_VIDEO` / `ACTION_REMOVE_VIDEO_BY_VIDEO_ID`) made in the bridge's own tab on a youtube.com page (any YouTube page it already shows, else `https://www.youtube.com/`), in the page's MAIN world, with the page's `ytcfg` context and the `SAPISIDHASH` authorization yt-dlp computes (`_get_sid_authorization_header`). Never in the user's tabs. A service worker fetch cannot make these calls: it carries the extension's `Origin`, which fetch cannot change. The endpoint and field names come from yt-dlp and public client code; they are tested against hand-written answers only, not against YouTube. `triage` and `propose` (`extension/agent-writes.js`) are off by default: while 设置 → MCP → 允许 Agent 改分拣台 (`mcpWrites`, under the read switch and greyed out while it or 允许 MCP 连接 is off) is off they fail with `TRIAGE_WRITES_OFF`, and while the read switch is off with `PERSONAL_DATA_OFF`. The extension checks every parameter before writing anything (`BAD_PARAMS`: a bvid is `BV` and 10 letters or digits, a folder id is digits or `toview`, lengths as below). diff --git a/extension/agent-collections.js b/extension/agent-collections.js index 1a10ae1d..8a7bc846 100644 --- a/extension/agent-collections.js +++ b/extension/agent-collections.js @@ -14,6 +14,10 @@ const ITEM_RESERVE_MS = 12_000; const WRITE_RESERVE_MS = 22_000; const STOP = [MdgErrors.CODE.THROTTLED, MdgErrors.CODE.NOT_LOGGED_IN]; + // A site's state read can lag its writes (YouTube: about 12 s): an item written here counts as written for this long, + // so an undo right after is not skipped as already done. In memory: a restarted worker forgets, and reads again. + const WRITTEN_MS = 30_000; + const written = new Map(); // "site|kind|id|item" → { state, at } const ITEM = /^[\w-]{1,64}$/; const fail = (message, code) => Object.assign(new Error(message), code ? { code } : {}); @@ -57,6 +61,11 @@ if (off) throw off; const at = { kind, id: p.id }; const want = op === "add"; + const slot = (item) => [adapter.id, kind, p.id ?? "", item].join("|"); + const has = async (item) => { + const w = written.get(slot(item)); + return w && Date.now() - w.at < WRITTEN_MS ? w.state : adapter.has({ ...at, item }, deadline); + }; const results = []; const sent = new Set(); // written, whatever the answer: a write that timed out or failed may still have landed let stopped = ""; @@ -65,14 +74,16 @@ if (Date.now() + ITEM_RESERVE_MS > deadline) { stopped = "TIMEOUT"; break; } let before; try { - before = await adapter.has({ ...at, item }, deadline); + before = await has(item); if (before === want) { results.push({ item, status: "unchanged", before }); continue; } if (dryRun) { results.push({ item, status: "would_change", before }); continue; } if (Date.now() + WRITE_RESERVE_MS > deadline) { stopped = "TIMEOUT"; break; } sent.add(item); - await adapter.write({ ...at, op, item }, deadline); - await sleep(SETTLE_MS); - const after = await adapter.has({ ...at, item }, deadline); + // true: the site's own answer confirms the change, so no read after (it may lag); else read after to confirm. + const confirmed = (await adapter.write({ ...at, op, item }, deadline)) === true; + if (!confirmed) await sleep(SETTLE_MS); + const after = confirmed ? want : await adapter.has({ ...at, item }, deadline); + if (after === want) written.set(slot(item), { state: want, at: Date.now() }); results.push({ item, status: after === want ? "done" : "unconfirmed", before, after }); } catch (e) { results.push({ item, status: "failed", ...(before != null && { before }), error: String(e?.message || e) }); @@ -116,4 +127,5 @@ collection: (params, deadline) => run(list, params, deadline), collectionWrite: (params, deadline) => run(write, params, deadline) }); + globalThis.MdgAgentCollections = { forget: () => written.clear() }; // for the selftest: each case starts with none })(); diff --git a/extension/agent-collections.selftest.js b/extension/agent-collections.selftest.js index a0987ae6..6f88def8 100644 --- a/extension/agent-collections.selftest.js +++ b/extension/agent-collections.selftest.js @@ -48,7 +48,7 @@ ctx.chrome = { storage: { sync: { get: async (k) => Object.fromEntries([k].flat( const ID = (n) => `vid${String(n).padStart(8, "0")}`; // 11 characters let lists; // playlistId → Set of video ids let throttleAt; // the request number (1-based) answered 429, 0 for none -let ignoreWrites; // edit_playlist answers 200 but changes nothing +let ignoreWrites; // edit_playlist answers STATUS_FAILED and changes nothing const ok = (json) => ({ status: 200, url: "https://www.youtube.com/youtubei/v1/x", json }); const pvr = (id, title) => ({ playlistVideoRenderer: { videoId: id, setVideoId: `s${id}`, title: { runs: [{ text: title }] }, shortBylineText: { runs: [{ text: "Chan" }] }, lengthSeconds: "125", isPlayable: true } }); function fakeYt(endpoint, body) { @@ -59,7 +59,8 @@ function fakeYt(endpoint, body) { } if (endpoint === "browse/edit_playlist") { const [a] = body.actions; - if (!ignoreWrites) a.action === "ACTION_ADD_VIDEO" ? lists[body.playlistId].add(a.addedVideoId) : lists[body.playlistId].delete(a.removedVideoId); + if (ignoreWrites) return ok({ status: "STATUS_FAILED" }); + a.action === "ACTION_ADD_VIDEO" ? lists[body.playlistId].add(a.addedVideoId) : lists[body.playlistId].delete(a.removedVideoId); return ok({ status: "STATUS_SUCCEEDED" }); } throw new Error(`no fake for ${endpoint}`); @@ -75,6 +76,7 @@ const reset = () => { calls.length = 0; gaps.length = 0; yt = fakeYt; + ctx.MdgAgentCollections.forget(); }; const far = () => Date.now() + 600_000; const write = (params, deadline = far()) => M.collectionWrite({ site: "youtube", ...params }, deadline); @@ -133,7 +135,7 @@ check("dryRun: each item's state and what would change; nothing written", async assert.deepStrictEqual([...lists.WL], [ID(1)]); }); -check("a write: no-op skipped, the rest written one at a time 1.5–3 s apart, confirmed by a read after, undo given", async () => { +check("a write: no-op skipped, the rest written one at a time 1.5–3 s apart, confirmed by YouTube's answer (no read after), undo given", async () => { const r = await write({ op: "add", kind: "watch_later", items: [ID(1), ID(2), ID(3)] }); assert.deepStrictEqual(plain(r.data.results), [ { item: ID(1), status: "unchanged", before: true }, @@ -143,7 +145,7 @@ check("a write: no-op skipped, the rest written one at a time 1.5–3 s apart, c assert.deepStrictEqual(plain(r.data.done), [ID(2), ID(3)]); assert.deepStrictEqual(plain(r.data.undo), { site: "youtube", op: "remove", kind: "watch_later", items: [ID(2), ID(3)] }); assert.deepStrictEqual(sent("browse/edit_playlist").map(([, b]) => plain(b)), [ID(2), ID(3)].map((v) => ({ playlistId: "WL", actions: [{ action: "ACTION_ADD_VIDEO", addedVideoId: v }] })), "ID(1) was already there: not sent"); - assert.deepStrictEqual(calls.map(([e]) => e), ["playlist/get_add_to_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist", "playlist/get_add_to_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist", "playlist/get_add_to_playlist"]); + assert.deepStrictEqual(calls.map(([e]) => e), ["playlist/get_add_to_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist", "playlist/get_add_to_playlist", "browse/edit_playlist"]); // A call gap is what is left of CALL_GAP_MS (at most 1000, less under load); an item gap is at least 1500. const itemGaps = gaps.filter((g) => g > 1000); assert.strictEqual(itemGaps.length, 2, "a gap before each item after the first"); @@ -169,14 +171,27 @@ check("a playlist by id: remove; a playlist that is not the user's fails the ite assert.strictEqual((await write({ op: "add", kind: "playlist", items: [ID(5)] })).data.results[0].status, "failed", "playlist needs an id"); }); -check("a write the read after does not see is unconfirmed, and undo still takes it", async () => { +check("a write YouTube answers as not made is failed, and undo still takes it", async () => { ignoreWrites = true; const r = await write({ op: "add", kind: "watch_later", items: [ID(2)] }); - assert.deepStrictEqual(plain(r.data.results), [{ item: ID(2), status: "unconfirmed", before: false, after: false }]); + assert.deepStrictEqual(plain(r.data.results.map((x) => [x.status, x.before])), [["failed", false]]); + assert.match(r.data.results[0].error, /did not make the change \(STATUS_FAILED\)/); assert.deepStrictEqual(plain(r.data.done), []); assert.deepStrictEqual(plain(r.data.undo.items), [ID(2)]); }); +check("YouTube's state read lags its writes (about 12 s): an undo right after goes by what was written, not skipped", async () => { + const r = await write({ op: "add", kind: "watch_later", items: [ID(2)] }); + assert.deepStrictEqual(plain(r.data.done), [ID(2)]); + const lagging = (e, b) => (e === "playlist/get_add_to_playlist" ? ok({ contents: [{ addToPlaylistRenderer: { playlists: [{ playlistAddToOptionRenderer: { playlistId: "WL", containsSelectedVideos: "NONE" } }] } }] }) : fakeYt(e, b)); + yt = lagging; + calls.length = 0; + const u = await M.collectionWrite(plain(r.data.undo), far()); + assert.deepStrictEqual(plain(u.data.results), [{ item: ID(2), status: "done", before: true, after: false }]); + assert.deepStrictEqual(calls.map(([e]) => e), ["browse/edit_playlist"], "no read: it was written moments ago"); + assert.ok(!lists.WL.has(ID(2))); +}); + check("a write that fails after it went out (HTTP 500, a timeout) is failed, and undo still takes it", async () => { yt = (e, b) => { const r = fakeYt(e, b); @@ -189,14 +204,14 @@ check("a write that fails after it went out (HTTP 500, a timeout) is failed, and }); check("429 mid-batch stops it: THROTTLED, done and not-done lists, no request after", async () => { - throttleAt = 4; // ID(2) takes 3 requests (read, write, read); the 4th is ID(3)'s read before + throttleAt = 3; // ID(2) takes 2 requests (read, write); the 3rd is ID(3)'s read before const r = await write({ op: "add", kind: "watch_later", items: [ID(2), ID(3), ID(4)] }); assert.strictEqual(r.ok, true); assert.strictEqual(r.data.stopped, "THROTTLED"); assert.deepStrictEqual(plain(r.data.done), [ID(2)]); assert.deepStrictEqual(plain(r.data.notDone), [ID(4)]); assert.deepStrictEqual(plain(r.data.results.map((x) => x.status)), ["done", "failed"]); - assert.strictEqual(calls.length, 4, "nothing asked after the 429"); + assert.strictEqual(calls.length, 3, "nothing asked after the 429"); assert.deepStrictEqual(plain(r.data.undo.items), [ID(2)]); }); diff --git a/extension/agent-sites/youtube.js b/extension/agent-sites/youtube.js index c15a1dfc..5dd1b189 100644 --- a/extension/agent-sites/youtube.js +++ b/extension/agent-sites/youtube.js @@ -168,7 +168,10 @@ async function write({ op, kind, id, item }, deadline) { const action = op === "add" ? { action: "ACTION_ADD_VIDEO", addedVideoId: item } : { action: "ACTION_REMOVE_VIDEO_BY_VIDEO_ID", removedVideoId: item }; - await api("browse/edit_playlist", { playlistId: playlistOf(kind, id), actions: [action] }, deadline); + // Its answer says whether YouTube made the change: the state read (get_add_to_playlist) lags it by about 12 s. + const json = await api("browse/edit_playlist", { playlistId: playlistOf(kind, id), actions: [action] }, deadline); + if (json.status !== "STATUS_SUCCEEDED") throw fail(`YouTube did not make the change (${json.status || "no status"}).`); + return true; } MdgAgentSites.register({ diff --git a/mcp/README.md b/mcp/README.md index a4279f05..3004068b 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -29,7 +29,7 @@ MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823── 你自己 YouTube 的收藏有两个工具。MoonDigest 不为它们做页面,Agent 就是界面: - `list_collection({ site, kind, id?, cursor?, raw? })`:`site` 写 `bilibili` 时 `kind` 只有 `watch_later`(稍后再看);B站 的收藏夹、关注和历史仍用上面三个工具。`site` 写 `youtube` 时 `kind` 是 `watch_later`(稍后观看)、`playlists`(你的播放列表)、`playlist`(`id` 那个播放列表里的视频)、`likes`(赞过的视频)、`subscriptions`(订阅)、`history`(历史记录)。一次一页,把返回的 `cursor` 传回去读下一页。和上面三个用同一个开关,关着时返回 `PERSONAL_DATA_OFF`。 -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频加进或移出 B站稍后再看、YouTube 稍后观看(都是 `kind: "watch_later"`)或你自己的 YouTube 播放列表(`kind: "playlist"`, `id`)。`items` 是 BV 号或 YouTube 视频 id。赞和订阅只能读。默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`:把视频加进或移出 B站稍后再看、YouTube 稍后观看(都是 `kind: "watch_later"`)或你自己的 YouTube 播放列表(`kind: "playlist"`, `id`)。`items` 是 BV 号或 YouTube 视频 id。赞和订阅只能读。默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改我的收藏」,关着时返回 `COLLECTION_WRITES_OFF`;`dryRun: true` 只读、不改,只要读的开关。一次最多 20 个,一个一个改,间隔 1.5 到 3 秒;每个先读一次,已经是那样的跳过,改完再读一次确认(YouTube 以它自己的写入回应为准:它的状态要十来秒才更新)。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 改分拣台的两个工具,默认关闭,要在「允许 Agent 读我的收藏、关注和历史」下面再打开「允许 Agent 改分拣台」,关着时返回 `TRIAGE_WRITES_OFF`。参数整份先检查,有一处不对就什么都不写,返回 `BAD_PARAMS`: @@ -156,7 +156,7 @@ These return one page per call and run one at a time, a second apart. Two tools work on your own YouTube collections. MoonDigest has no pages for them: the AI tool is the UI. - `list_collection({ site, kind, id?, cursor?, raw? })`: `site` `bilibili` has only `kind` `watch_later` (稍后再看); Bilibili favorites, follows and history stay in the three tools above. `site` `youtube` has `kind` `watch_later`, `playlists` (your playlists), `playlist` (the videos of playlist `id`), `likes`, `subscriptions` or `history`. One page a call; pass the returned `cursor` back for the next. Same switch as the three above: `PERSONAL_DATA_OFF` while it is off. -- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos to or removes them from Bilibili 稍后再看 or YouTube Watch later (both `kind: "watch_later"`) or one of your own YouTube playlists (`kind: "playlist"`, `id`). `items` are BV ids or YouTube video ids. Likes and subscriptions are read-only. Off by default: turn on 允许 Agent 改我的收藏 under 允许 Agent 读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. +- `collection_write({ site, op, kind, id?, items, dryRun?, raw? })`: adds videos to or removes them from Bilibili 稍后再看 or YouTube Watch later (both `kind: "watch_later"`) or one of your own YouTube playlists (`kind: "playlist"`, `id`). `items` are BV ids or YouTube video ids. Likes and subscriptions are read-only. Off by default: turn on 允许 Agent 改我的收藏 under 允许 Agent 读我的收藏、关注和历史; while it is off a write returns `COLLECTION_WRITES_OFF`, and `dryRun: true` (reads only) needs only the read switch. At most 20 a call, one at a time, 1.5 to 3 s apart; each is read first (already so: skipped) and again after (confirmed; YouTube by its own answer to the write, since its state takes about 12 s to catch up). Rate limiting (`THROTTLED`) or a lost login stops the batch; the result says what was done and not tried, and gives the call that undoes it. Undo is not exact: a video removed and added back goes to the end of the list, not its old place; a video that was in a playlist more than once is removed by its id and added back once. Two tools change the 分拣台. Both are off by default: turn on 允许 Agent 改分拣台 under 允许 Agent 读我的收藏、关注和历史. While it is off they return `TRIAGE_WRITES_OFF`. Each call is checked whole first: one bad field writes nothing and returns `BAD_PARAMS`. From bce942fd2ecd736f74545d43c92c3dcb7e193d4a Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:49:17 +0800 Subject: [PATCH 37/37] =?UTF-8?q?MCP=20=E8=AF=BB=E6=94=B6=E8=97=8F?= =?UTF-8?q?=EF=BC=9AYouTube=E3=80=8C=E8=B5=9E=E8=BF=87=E7=9A=84=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E3=80=8D=E8=83=BD=E7=BF=BB=E9=A1=B5=EF=BC=88=E6=96=B0?= =?UTF-8?q?=E7=89=88=E5=88=97=E8=A1=A8=E7=9A=84=E4=B8=8B=E4=B8=80=E9=A1=B5?= =?UTF-8?q?=20token=20=E5=9C=A8=20continuationItemViewModel=20=E9=87=8C?= =?UTF-8?q?=E6=B7=B1=E4=B8=80=E5=B1=82=EF=BC=8C=E5=8E=9F=E6=9D=A5=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=88=B0=E5=89=8D=20100=20=E4=B8=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/agent-sites/youtube.js | 6 +++++- extension/agent-sites/youtube.selftest.js | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/extension/agent-sites/youtube.js b/extension/agent-sites/youtube.js index 5dd1b189..344d3e5b 100644 --- a/extension/agent-sites/youtube.js +++ b/extension/agent-sites/youtube.js @@ -128,7 +128,11 @@ find(json, ["channelRenderer"]).flatMap(([, v]) => (v.channelId ? [{ channelId: v.channelId, url: `https://www.youtube.com/channel/${v.channelId}`, title: text(v.title) }] : [])); // The next page's token: the continuationItemRenderer's continuationCommand, "" at the end. - const cursorOf = (json) => find(find(json, ["continuationItemRenderer"]), ["continuationCommand"])[0]?.[1].token || ""; + // The next page's token. Old lists: continuationItemRenderer → … → continuationCommand.token. Lists of lockups (Liked + // videos): continuationItemViewModel → continuationCommand → innertubeCommand → continuationCommand.token, so a + // continuationCommand without a token is looked inside. + const tokenIn = (node) => (typeof node?.token === "string" ? node.token : find(node, ["continuationCommand"]).map(([, v]) => tokenIn(v)).find(Boolean)); + const cursorOf = (json) => find(json, ["continuationItemRenderer", "continuationItemViewModel"]).map(([, v]) => tokenIn(v)).find(Boolean) || ""; // The Save dialog's list (playlist/get_add_to_playlist): the user's own playlists, Watch later (WL) included, and // whether each holds the video. diff --git a/extension/agent-sites/youtube.selftest.js b/extension/agent-sites/youtube.selftest.js index 4bff7ae8..72b18a56 100644 --- a/extension/agent-sites/youtube.selftest.js +++ b/extension/agent-sites/youtube.selftest.js @@ -88,5 +88,12 @@ const sha1 = (s) => crypto.createHash("sha1").update(s).digest("hex"); const bad = inPage(yt.test.ytApi, { cfg, cookie: "SAPISID=s", answer: () => ({ status: 200, url: "", text: async () => "" }) }); assert.strictEqual((await bad.run("browse", {})).json, null); - console.log("agent-sites youtube selftest ok"); + // The next page's token in both list shapes (Liked videos is lockups since 2026: the token is one command deeper). +{ + const { cursorOf } = S.get("youtube").test; + const renderer = { contents: [{ continuationItemRenderer: { continuationEndpoint: { continuationCommand: { token: "OLD" } } } }] }; + const viewModel = { contents: [{ lockupViewModel: { contentId: "x" } }, { continuationItemViewModel: { continuationCommand: { innertubeCommand: { continuationCommand: { token: "NEW", request: "CONTINUATION_REQUEST_TYPE_BROWSE" } } } } }] }; + assert.deepStrictEqual([cursorOf(renderer), cursorOf(viewModel), cursorOf({ contents: [] })], ["OLD", "NEW", ""]); +} +console.log("agent-sites youtube selftest ok"); })().catch((e) => (console.error(e), process.exit(1)));