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 9fe3a696..459c2850 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 读,只读不改。两种接法: +Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能借你浏览器的登录状态拿到视频的字幕、章节、简介和热门评论,还能截取任意一秒的画面。用 MCP 时,agent 还能借你的登录搜 B站和 YouTube 视频。不下载视频,不花 AI 额度,读不到你的 Key 和 AI 对话。你的收藏夹、关注、观看历史和分拣台里的标签、备注,只有打开「允许 Agent 读我的收藏、关注和历史」后才能通过 MCP 读;B站稍后再看、YouTube 稍后观看和 YouTube 播放列表,再打开「允许 Agent 改我的收藏」后 agent 才能改。两种接法: - **skill**(agent 能操控浏览器,如 ego、Claude in Chrome、Playwright): @@ -61,7 +61,7 @@ Agent 碰到视频通常只能看标题和封面。装了 MoonDigest,agent 能 mkdir -p ~/.claude/skills && cp -r MoonDigest/skills/moondigest ~/.claude/skills/ ``` -- **MCP**(Claude Code、Cursor、Codex 等):在设置 → 高级打开「允许 MCP 连接」(用时再开,不用时关掉),再装服务(要 Node 20+): +- **MCP**(Claude Code、Cursor、Codex 等):在设置 → MCP 打开「允许 MCP 连接」(用时再开,不用时关掉),再装服务(要 Node 20+): ```sh git clone https://github.com/PathGao/MoonDigest.git diff --git a/docs/AGENT.md b/docs/AGENT.md index 25b6e34a..235ef7c1 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -1,8 +1,8 @@ # 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`); likewise the user's collections on other sites (§2 `collection`). Writes only through the MCP server and only while the user allows them: the 分拣台 (§2, `triage` / `propose`: local results directly, Bilibili changes as proposals the user confirms) and the user's collections on other sites (`collectionWrite`). For sites beyond the Bilibili 分拣台 MoonDigest has no pages of its own: the agent is the UI. -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) @@ -50,7 +50,7 @@ Errors: `NO_VIDEO` (no `
+## Agent(MCP) + +在设置 → MCP 打开「允许 Agent 改分拣台」后,Claude Code、Cursor 这类 Agent 可以通过 [MCP 服务](../mcp/README.md) 写入 AI 判断、保留、备注和视频标签。它写的判断卡片上标「 Agent 判断」;每次写入在分拣台里是一步,按 U 撤销,你之后又改过的不动。 + +取消收藏、移动和复制它只能提议。进度胶囊写「 Agent 的 N 条收藏夹提议等你确认」,点「查看」打开那个收藏夹、列出这些视频:确认后照分拣台自己的操作在 B站执行(按 U 撤销),点「拒绝」删掉提议,B站不变。 + ## 快捷键 | 按键 | 作用 | diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 0f04ddf4..fb0a7884 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. (() => { const SERVER_URL = "ws://127.0.0.1:47823/"; const ALARM = "agent-bridge"; @@ -11,30 +12,19 @@ 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; + // 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 → 允许 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) { + const on = MdgSettings.normalizeSwitches(await chrome.storage.sync.get(keys)); + const off = keys.find((k) => !on[k]); + if (!off) return null; + const [code, message] = MCP_SWITCHES[off]; + return Object.assign(new Error(message), { code }); } // --- tabs -------------------------------------------------------------------- @@ -63,11 +53,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,13 +66,19 @@ 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; - if (own) { - own = { tabId: own.tabId, key, pageKey: null, staleUrl: tab.url || "" }; - await chrome.tabs.update(own.tabId, { url }); + return openOwn(own && tab, key, url); + } + + // Loads url in the own tab (tab, or a new one when null) and saves it. + async function openOwn(tab, key, url) { + let own; + if (tab) { + own = { tabId: tab.id, key, pageKey: null, staleUrl: tab.url || "" }; + await chrome.tabs.update(tab.id, { url }); } else { const created = await chrome.tabs.create({ url, active: false }); // Once shown for a frame its video may play on in the background: keep it silent. @@ -93,10 +89,51 @@ return own; } - // Retries until the tab's content script answers; get and frame are safe to repeat. + // For adapters' collection calls (§5): runs func(...args) in the MAIN world of the own tab on any page of site, loading + // url there when it shows another site, once ready() there returns { ok: true }. func runs once, never again on a + // failure: it may be a write. Resolves to { ok, data: func's result } or { ok: false, error, code? }. + function inSitePage(site, url, ready, func, args, deadline) { + 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." }; + // 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 && (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, [])); + if (!up.ok) return up; + try { + const data = await within(run(func, args)(target.tabId), deadline); + return data === LATE ? late(target.tabId) : { ok: true, data }; + } catch (e) { + return { ok: false, error: `The ${site.id} tab did not run the call: ${e?.message || e}` }; + } + }); + } + + // A page's script may never settle (a page can make any await hang, e.g. by setting Object.prototype.then): wait for + // it only until `until`, then LATE. On LATE in the own tab, that tab leaves the page so nothing of it keeps running. + const LATE = Symbol("late"); + const within = (p, until) => new Promise((res, rej) => { + const t = setTimeout(() => res(LATE), Math.max(0, until - Date.now())); + p.then((v) => { clearTimeout(t); res(v); }, (e) => { clearTimeout(t); rej(e); }); + }); + async function late(tabId) { + if ((await loadOwn())?.tabId === tabId) await chrome.tabs.update(tabId, { url: "about:blank" }).catch(() => {}); + return { ok: false, code: "TIMEOUT", error: "The page did not answer in time." }; + } + + // 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 +145,8 @@ else target.staleUrl = null; } if (ready) { - reply = await chrome.tabs.sendMessage(target.tabId, { type: "agent-call", method, params }).catch(() => undefined); + reply = await within(Promise.resolve().then(() => ask(target.tabId)), until).catch(() => undefined); + if (reply === LATE) return late(target.tabId); if (reply && typeof reply.ok === "boolean") return reply; } if (Date.now() > until) { @@ -146,11 +184,12 @@ if (method === "search") return MdgAgentSearch.search(params, deadline); // agent-search.js, no tab 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 url = params && typeof params.url === "string" ? MdgAgentSites.canonical(params.url) : null; + const key = url ? 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); + 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." }; @@ -158,7 +197,7 @@ 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 ? videoKey(tab.url || "") : null; + own.pageKey = tab ? MdgAgentSites.keyOf(tab.url || "") : null; } await saveOwn(own); return reply; @@ -248,5 +287,5 @@ Promise.resolve(o.isEnabled()).then(apply, () => apply(false)); } - globalThis.MdgAgentBridge = { start, addMethods }; + globalThis.MdgAgentBridge = { start, addMethods, switchOff, inSitePage }; })(); diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 1fc979fb..aee1ea40 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -7,13 +7,16 @@ 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: sites.js (MdgSites.cleanComment) and what it needs, the registry, the sites, the bridge. +const SOURCES = ["common.js", "errors.js", "limits.js", "settings.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(); }; -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. +// sync: chrome.storage.sync (the MCP switches). +function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = false, granted = [], inject = null, sync = {} }) { + 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 +29,14 @@ 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) => { + // out of the vm realm + env.injected.push(d.files ? { tabId: d.target.tabId, files: [...d.files] } : { tabId: d.target.tabId, world: d.world, args: JSON.parse(JSON.stringify(d.args)) }); + 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), @@ -33,6 +44,7 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = }, storage: { local: { set: (o) => Object.assign(env.storage, o) }, + sync: { get: async (keys) => Object.fromEntries([keys].flat().filter((k) => k in sync).map((k) => [k, sync[k]])) }, session: { // shared across makeEnv calls to simulate a service worker restart get: async (k) => (k in session ? { [k]: JSON.parse(session[k]) } : {}), set: async (o) => { for (const k in o) session[k] = JSON.stringify(o[k]); }, @@ -40,8 +52,9 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = }, }, tabs: { - // url filters are ignored (every test tab is a video site's); active and windowId filter. - query: async (q = {}) => tabs.filter((t) => (q.active === undefined || !!t.active === q.active) && (q.windowId === undefined || t.windowId === q.windowId)), + // url, active and windowId filter; a url pattern's host is matched, "*." as any subdomain. + query: async (q = {}) => tabs.filter((t) => (q.active === undefined || !!t.active === q.active) && (q.windowId === undefined || t.windowId === q.windowId) && + (q.url === undefined || q.url.some((p) => { const h = p.split("/")[2]; const host = new URL(t.url).hostname; return h.startsWith("*.") ? host === h.slice(2) || host.endsWith(h.slice(1)) : host === h; }))), get: async (id) => { const t = tabs.find((x) => x.id === id); if (!t) throw new Error("no tab"); return t; }, create: async ({ url, active }) => { const t = { id: nextTab++, url, active, windowId: 1 }; tabs.push(t); env.created.push(t); return t; }, update: async (id, { url, active }) => { // the URL commits when the test says so, or at once with commitNow @@ -72,7 +85,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", @@ -81,6 +95,7 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = }); env.tabs = tabs; env.addMethods = (m) => ctx.MdgAgentBridge.addMethods(m); + env.inSitePage = (...a) => ctx.MdgAgentBridge.inSitePage(...a); return env; } @@ -410,6 +425,84 @@ check("a call still queued when the server has given up is dropped", async () => assert.equal(env.updated.length, 0); }); +// 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: "content", +}); + +// inSitePage: an adapter's collection call (§5) in the own tab on any page of the site. +const DEMO_HOME = "https://www.demo.test/"; +const ready = () => "ready"; +const func = () => "func"; +const sitePage = (o) => { + let readyAsks = 0; // the first ask: not ready yet + const env = makeEnv({ enabled: true, commitNow: true, granted: ["https://www.demo.test/*"], ...o, + inject: (tab, d) => (d.func === ready ? (readyAsks++ ? { ok: true } : undefined) : o.inject ? o.inject() : { status: 200 }) }); + demoSite(env); + env.run = async () => { + const p = env.inSitePage(env.sites.get("demo"), DEMO_HOME, ready, func, ["x"], Date.now() + 60_000); + for (let i = 0; i < 5; i++) { await settle(); env.runTimers(); } + return JSON.parse(JSON.stringify(await p)); + }; + env.funcRuns = () => env.injected.filter((i) => i.args.length).length; + return env; +}; + +check("inSitePage: no own tab opens the site muted in the background, waits until ready, runs func once; the user's tab untouched", async () => { + const env = sitePage({ tabs: [{ id: 7, url: DEMO, active: true }] }); + assert.deepEqual(await env.run(), { ok: true, data: { status: 200 } }); + assert.deepEqual(env.created.map((t) => [t.url, t.active]), [[DEMO_HOME, false]]); + assert.ok(env.injected.every((i) => i.tabId === env.created[0].id && i.world === "MAIN")); + assert.equal(env.funcRuns(), 1); + assert.equal(env.injected.length, 3, "ready asked again until the page answers"); +}); + +check("inSitePage: an own tab on another site is navigated; one already on the site is used as it is", async () => { + const own = (url) => ({ session: { agentBridgeTab: JSON.stringify({ tabId: 50, key: "bilibili:BV1:1", pageKey: null, staleUrl: null }) }, tabs: [{ id: 50, url }] }); + let env = sitePage(own("https://www.bilibili.com/video/BV1/")); + assert.equal((await env.run()).ok, true); + assert.deepEqual([env.created.length, env.updated], [0, [{ id: 50, url: DEMO_HOME }]]); + env = sitePage(own("https://www.demo.test/v/9")); + assert.equal((await env.run()).ok, true); + assert.deepEqual([env.created.length, env.updated.length], [0, 0]); +}); + +check("inSitePage: func failing is not run again (it may be a write); no access is NO_ACCESS with nothing injected", async () => { + const env = sitePage({ tabs: [], inject: () => { throw new Error("frame gone"); } }); + const r = await env.run(); + assert.equal(r.ok, false); + assert.match(r.error, /frame gone/); + assert.equal(env.funcRuns(), 1); + const denied = sitePage({ granted: [] }); + assert.equal((await denied.run()).code, "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); + 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" }); +}); + + (async () => { let failed = 0; for (const [name, fn] of cases) { diff --git a/extension/agent-collections.js b/extension/agent-collections.js new file mode 100644 index 00000000..8a7bc846 --- /dev/null +++ b/extension/agent-collections.js @@ -0,0 +1,131 @@ +// MoonDigest's agent interface, docs/AGENT.md §2 `collection` and `collectionWrite`: the user's collections on a site +// whose adapter has `collections` (agent-sites/, §5), read and changed with no page of MoonDigest's own: the agent is +// the UI. The rules every site's writes get are here, not in the adapters: off until the user allows it, at most +// MAX_ITEMS a call, one item at a time ITEM_GAP apart, each read before (no-op skipped) and after (confirmed), stop at +// THROTTLED or a lost login, and the inverse op to undo what was done. +(() => { + const MAX_ITEMS = 20; + const ITEM_GAP = [1500, 3000]; + const CALL_GAP_MS = 1000; + const SETTLE_MS = 1000; // between a write and the read that confirms it + // An item is not started with less left before the deadline, nor its write sent with less than WRITE_RESERVE_MS + // (write, settle and the read after, each request capped at 10 s by the adapter): the rest are returned undone, so a + // write is never sent without time to confirm it. Each request also ends by the deadline, before the server's. + 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 } : {}); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + // The site and kind, checked: { adapter, kind, ops } (ops: the writes the kind takes). + function target(params) { + const p = params && typeof params === "object" ? params : {}; + const adapter = typeof p.site === "string" ? MdgAgentSites.get(p.site) : null; + if (!adapter?.collections) { + throw fail(`site must be one of ${MdgAgentSites.list().filter((s) => s.collections).map((s) => `"${s.id}"`).join(", ")}.`, "BAD_PARAMS"); + } + const ops = Object.hasOwn(adapter.collections, p.kind) ? adapter.collections[p.kind] : null; + if (!ops) throw fail(`${adapter.id} has no kind ${p.kind}. Kinds: ${Object.keys(adapter.collections).join(", ")}.`, "UNSUPPORTED_KIND"); + return { p, adapter, kind: p.kind, ops }; + } + + async function list(params, deadline) { + const { p, adapter, kind } = target(params); + if (p.cursor != null && (typeof p.cursor !== "string" || p.cursor.length > 4096)) throw fail("cursor must be the cursor a previous list_collection returned.", "BAD_PARAMS"); + const off = await MdgAgentBridge.switchOff("mcpPersonalData"); + if (off) throw off; + const { items, cursor } = await adapter.list({ kind, id: p.id, cursor: p.cursor || "" }, deadline); + return { site: adapter.id, kind, ...(p.id != null && { id: String(p.id) }), items, cursor }; + } + + // → { site, op, kind, id?, dryRun, results: [{ item, status, before?, after?, error? }], done, notDone, stopped?, undo }. + // status: unchanged (already as asked: skipped), would_change (dryRun), done (confirmed by the read after), + // unconfirmed (sent, but the read after still shows the old state), failed. stopped: the code that ended the batch + // (THROTTLED, NOT_LOGGED_IN, or TIMEOUT when the call's time ran out); notDone are the items not tried. + async function write(params, deadline) { + const { p, adapter, kind, ops } = target(params); + const op = p.op; + if (!ops.includes(op)) throw fail(ops.length ? `op must be ${ops.map((o) => `"${o}"`).join(" or ")} for ${kind}.` : `${adapter.id} ${kind} is read only.`, "UNSUPPORTED_KIND"); + const items = p.items; + if (!Array.isArray(items) || !items.length || items.length > MAX_ITEMS) throw fail(`items must be 1 to ${MAX_ITEMS} ids.`, "BAD_PARAMS"); + if (!items.every((i) => typeof i === "string" && ITEM.test(i)) || new Set(items).size !== items.length) throw fail("items must be distinct ids.", "BAD_PARAMS"); + const dryRun = p.dryRun === true; + // dryRun only reads: the read switch alone. + const off = await MdgAgentBridge.switchOff("mcpPersonalData", ...(dryRun ? [] : ["mcpCollectionWrite"])); + 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 = ""; + for (const [n, item] of items.entries()) { + if (n) await sleep(ITEM_GAP[0] + Math.random() * (ITEM_GAP[1] - ITEM_GAP[0])); + if (Date.now() + ITEM_RESERVE_MS > deadline) { stopped = "TIMEOUT"; break; } + let before; + try { + 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); + // 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) }); + if (STOP.includes(e?.code)) { stopped = e.code; break; } + } + } + const done = results.filter((r) => r.status === "done").map((r) => r.item); + // Every item sent may have landed (unconfirmed, or failed after the write went out): undo takes it too, and skips it + // if it had not, since undo reads each item first. + const undoItems = items.filter((i) => sent.has(i)); + return { + site: adapter.id, op, kind, ...(p.id != null && { id: String(p.id) }), dryRun, results, done, + notDone: items.slice(results.length), + ...(stopped && { stopped }), + undo: undoItems.length ? { site: adapter.id, op: want ? "remove" : "add", kind, ...(p.id != null && { id: String(p.id) }), items: undoItems } : null + }; + } + + // One call at a time, CALL_GAP_MS apart (a write's items have their own gaps). deadline: as agent-bridge.js handle. + let queue = Promise.resolve(); + let lastAt = 0; + function run(fn, params, deadline) { + const call = queue.then(async () => { + const wait = lastAt + CALL_GAP_MS - Date.now(); + if (wait > 0) await sleep(wait); + if (Date.now() > deadline) throw fail("The call waited too long behind earlier ones.", "TIMEOUT"); + try { + return await fn(params, deadline); + } finally { + lastAt = Date.now(); + } + }); + queue = call.catch(() => {}); + return call.then( + (data) => ({ ok: true, data }), + (e) => ({ ok: false, error: String(e?.message || e), ...(e?.code ? { code: String(e.code) } : {}) }) + ); + } + + MdgAgentBridge.addMethods({ + 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 new file mode 100644 index 00000000..6f88def8 --- /dev/null +++ b/extension/agent-collections.selftest.js @@ -0,0 +1,312 @@ +// /usr/local/bin/node extension/agent-collections.selftest.js +// The collection tools (agent-collections.js) over the real YouTube adapter (agent-sites/youtube.js), with a fake +// YouTube behind the bridge's inSitePage: hand-written youtubei answers in the shapes the adapter parses (fixtures, not +// recorded from YouTube). No request leaves this process. +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"); + +// The worker's load order up to agent-collections.js. Its gaps run at once and are recorded. +const gaps = []; +const fastGap = (fn, ms) => (ms > 0 && ms <= 3000 ? (gaps.push(ms), setImmediate(fn)) : setTimeout(fn, ms)); +const ctx = vm.createContext({ TextEncoder, URL, URLSearchParams, console, setTimeout: fastGap, clearTimeout, setInterval, clearInterval, AbortController }); +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-collections.js") > order.indexOf("agent-sites/youtube.js"), "agent-collections.js loads after the site adapters"); +let M = null; +const calls = []; +let yt = null; // the fake YouTube: (endpoint, body) → ytApi's answer +let inFlight = 0; +let maxInFlight = 0; +for (const file of order) { + if (file === "agent-collections.js") { + ctx.MdgAgentBridge = { + switchOff: ctx.MdgAgentBridge.switchOff, + addMethods: (m) => (M = m), + inSitePage: async (site, url, ready, func, args) => { + assert.strictEqual(site.id, "youtube"); + assert.strictEqual(func.name, "ytApi", "every YouTube call is one ytApi in the page"); + calls.push(args); + maxInFlight = Math.max(maxInFlight, ++inFlight); + await new Promise((r) => setImmediate(r)); + inFlight--; + return { ok: true, data: yt(...args) }; + } + }; + } + vm.runInContext(fs.readFileSync(path.join(__dirname, file), "utf8"), ctx); + if (file === "agent-collections.js") break; +} +assert.deepStrictEqual(Object.keys(M), ["collection", "collectionWrite"]); +const plain = (x) => JSON.parse(JSON.stringify(x)); + +let sync = {}; +ctx.chrome = { storage: { sync: { get: async (k) => Object.fromEntries([k].flat().filter((x) => x in sync).map((x) => [x, sync[x]])) } } }; + +// ---- fake YouTube state and answers ---- +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 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) { + if (throttleAt && calls.length === throttleAt) return { status: 429, url: "https://www.youtube.com/youtubei/v1/x", json: null }; + if (endpoint === "playlist/get_add_to_playlist") { + const v = body.videoIds[0]; + return ok({ contents: [{ addToPlaylistRenderer: { playlists: Object.entries(lists).map(([pl, set]) => ({ playlistAddToOptionRenderer: { playlistId: pl, title: { simpleText: pl }, containsSelectedVideos: set.has(v) ? "ALL" : "NONE" } })) } }] }); + } + if (endpoint === "browse/edit_playlist") { + const [a] = body.actions; + 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}`); +} + +const cases = []; +const check = (name, fn) => cases.push([name, fn]); +const reset = () => { + sync = { mcpPersonalData: true, mcpCollectionWrite: true }; + lists = { WL: new Set([ID(1)]), PLmine: new Set() }; + throttleAt = 0; + ignoreWrites = false; + 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); +const sent = (endpoint) => calls.filter(([e]) => e === endpoint); + +check("off: list is PERSONAL_DATA_OFF, a write COLLECTION_WRITES_OFF, a dryRun runs on the read switch alone; nothing asked of YouTube", async () => { + sync = { mcpCollectionWrite: true }; + assert.strictEqual((await M.collection({ site: "youtube", kind: "watch_later" }, far())).code, "PERSONAL_DATA_OFF"); + assert.strictEqual((await write({ op: "add", kind: "watch_later", items: [ID(2)] })).code, "PERSONAL_DATA_OFF", "the write switch alone does not open writes"); + 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, /允许 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); + const dry = await write({ op: "add", kind: "watch_later", items: [ID(2)], dryRun: true }); + assert.strictEqual(dry.ok, true); + assert.strictEqual(sent("browse/edit_playlist").length, 0); +}); + +check("refused before any request: unknown site and kind, writes to a read-only kind, bad and too many items", async () => { + const bad = [ + [{ site: "nope", op: "add", kind: "watch_later", items: [ID(2)] }, "BAD_PARAMS"], + [{ op: "add", kind: "tags", items: [ID(2)] }, "UNSUPPORTED_KIND"], + [{ op: "remove", kind: "likes", items: [ID(2)] }, "UNSUPPORTED_KIND"], + [{ op: "remove", kind: "subscriptions", items: ["UC123"] }, "UNSUPPORTED_KIND"], + [{ op: "delete", kind: "watch_later", items: [ID(2)] }, "UNSUPPORTED_KIND"], + [{ op: "add", kind: "__proto__", items: [ID(2)] }, "UNSUPPORTED_KIND"], + [{ op: "add", kind: "watch_later", items: [] }, "BAD_PARAMS"], + [{ op: "add", kind: "watch_later", items: Array.from({ length: 21 }, (_, i) => ID(i)) }, "BAD_PARAMS"], + [{ op: "add", kind: "watch_later", items: [ID(2), ID(2)] }, "BAD_PARAMS"], + [{ op: "add", kind: "watch_later", items: ["a b"] }, "BAD_PARAMS"], + [{ op: "add", kind: "watch_later", items: "x" }, "BAD_PARAMS"] + ]; + for (const [p, code] of bad) { + const r = await M.collectionWrite({ site: "youtube", ...p }, far()); + assert.strictEqual(r.ok, false, JSON.stringify(p)); + assert.strictEqual(r.code || "", code, JSON.stringify(p)); + } + assert.strictEqual((await M.collection({ site: "youtube", kind: "tags" }, far())).code, "UNSUPPORTED_KIND"); + assert.strictEqual(calls.length, 0); + // 20 is the limit, not 19. + const twenty = await write({ op: "add", kind: "watch_later", items: Array.from({ length: 20 }, (_, i) => ID(i + 100)), dryRun: true }); + assert.strictEqual(twenty.data.results.length, 20); +}); + +check("dryRun: each item's state and what would change; nothing written", async () => { + const r = await write({ op: "add", kind: "watch_later", items: [ID(1), ID(2)], dryRun: true }); + assert.deepStrictEqual(plain(r.data), { + site: "youtube", op: "add", kind: "watch_later", dryRun: true, + results: [{ item: ID(1), status: "unchanged", before: true }, { item: ID(2), status: "would_change", before: false }], + done: [], notDone: [], undo: null + }); + assert.strictEqual(sent("browse/edit_playlist").length, 0); + 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 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 }, + { item: ID(2), status: "done", before: false, after: true }, + { item: ID(3), status: "done", before: false, after: true } + ]); + 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", "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"); + assert.ok(itemGaps.every((g) => g >= 1500 && g <= 3000), itemGaps.join()); + // The undo runs as given and puts things back. + calls.length = 0; + const u = await M.collectionWrite(plain(r.data.undo), far()); + assert.deepStrictEqual(plain(u.data.done), [ID(2), ID(3)]); + assert.deepStrictEqual([...lists.WL], [ID(1)]); + assert.deepStrictEqual(plain(u.data.undo.items), [ID(2), ID(3)]); + assert.strictEqual(u.data.undo.op, "add"); +}); + +check("a playlist by id: remove; a playlist that is not the user's fails the item", async () => { + lists.PLmine.add(ID(5)); + const r = await write({ op: "remove", kind: "playlist", id: "VLPLmine", items: [ID(5)] }); + assert.deepStrictEqual(plain(r.data.results), [{ item: ID(5), status: "done", before: true, after: false }]); + assert.deepStrictEqual(plain(sent("browse/edit_playlist")[0][1]), { playlistId: "PLmine", actions: [{ action: "ACTION_REMOVE_VIDEO_BY_VIDEO_ID", removedVideoId: ID(5) }] }); + const other = await write({ op: "add", kind: "playlist", id: "PLsomeoneelse", items: [ID(5)] }); + assert.strictEqual(other.data.results[0].status, "failed"); + assert.match(other.data.results[0].error, /not one of the user's own playlists/); + assert.strictEqual(other.data.undo, null); + assert.strictEqual((await write({ op: "add", kind: "playlist", items: [ID(5)] })).data.results[0].status, "failed", "playlist needs an id"); +}); + +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.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); + return e === "browse/edit_playlist" ? { status: 500, url: "", json: null } : r; // applied, then the answer failed + }; + const r = await write({ op: "add", kind: "watch_later", items: [ID(2)] }); + assert.deepStrictEqual(plain(r.data.results.map((x) => x.status)), ["failed"]); + assert.deepStrictEqual(plain(r.data.undo), { site: "youtube", op: "remove", kind: "watch_later", items: [ID(2)] }); + assert.ok(lists.WL.has(ID(2)), "it had landed"); +}); + +check("429 mid-batch stops it: THROTTLED, done and not-done lists, no request after", async () => { + 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, 3, "nothing asked after the 429"); + assert.deepStrictEqual(plain(r.data.undo.items), [ID(2)]); +}); + +check("a captcha page, 403 and a lost login stop the batch too; a plain HTTP error fails only its item", async () => { + for (const [answer, code] of [ + [{ status: 200, url: "https://www.google.com/sorry/index?continue=x", json: null }, "THROTTLED"], + [{ status: 403, url: "", json: null }, "NOT_LOGGED_IN"], + [{ status: 401, loggedOut: true }, "NOT_LOGGED_IN"] + ]) { + reset(); + yt = () => answer; + const r = await write({ op: "add", kind: "watch_later", items: [ID(2), ID(3)] }); + assert.deepStrictEqual([r.data.stopped, plain(r.data.notDone), calls.length], [code, [ID(3)], 1], code); + } + reset(); + yt = (e, b) => (b.videoIds?.[0] === ID(2) ? { status: 500, url: "", json: null } : fakeYt(e, b)); + const r = await write({ op: "add", kind: "watch_later", items: [ID(2), ID(3)] }); + assert.deepStrictEqual(plain(r.data.results.map((x) => x.status)), ["failed", "done"]); + assert.strictEqual(r.data.stopped, undefined); +}); + +check("time for the read but not for write and confirm: the write is not sent, the item is returned; requests end by the deadline", async () => { + const r = await write({ op: "add", kind: "watch_later", items: [ID(2)] }, Date.now() + 15_000); + assert.deepStrictEqual([r.data.stopped, plain(r.data.notDone), plain(r.data.results), r.data.undo], ["TIMEOUT", [ID(2)], [], null]); + assert.deepStrictEqual(sent("browse/edit_playlist"), []); + assert.ok(calls[0][2] <= 10_000 && calls[0][2] >= 1000, `per-request timeout ${calls[0][2]}`); +}); + +check("time runs out: the items not started are returned, none half done", async () => { + const r = await write({ op: "add", kind: "watch_later", items: [ID(2), ID(3)] }, Date.now() + 5_000); + assert.deepStrictEqual([r.data.stopped, plain(r.data.notDone), calls.length], ["TIMEOUT", [ID(2), ID(3)], 0]); +}); + +check("lists: the browseId per kind, a playlist by id, the cursor to the next page; THROTTLED as an error", async () => { + const pages = []; + yt = (e, b) => { + pages.push(plain(b)); + if (b.browseId === "FEhistory") return ok({ sections: [ + { videoRenderer: { videoId: ID(7), title: { runs: [{ text: "Seen" }] }, ownerText: { runs: [{ text: "Up" }] }, lengthText: { simpleText: "1:02:03" } } }, + { lockupViewModel: { contentId: ID(8), contentType: "LOCKUP_CONTENT_TYPE_VIDEO", metadata: { lockupMetadataViewModel: { title: { content: "New look" }, + metadata: { contentMetadataViewModel: { metadataRows: [{ metadataParts: [{ text: { content: "Chan B" } }] }, { metadataParts: [{ text: { content: "3 views" } }] }] } } } } } }, + { lockupViewModel: { contentId: "PLx", contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST" } } + ] }); + if (b.browseId === "FEchannels") return ok({ contents: { x: [{ channelRenderer: { channelId: "UCa", title: { simpleText: "Chan A" } } }] } }); + if (b.browseId === "FEplaylist_aggregation") return ok({ items: [ + { lockupViewModel: { contentId: "PLmine", contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", metadata: { lockupMetadataViewModel: { title: { content: "Mine" } } } } }, + { gridPlaylistRenderer: { playlistId: "PLold", title: { runs: [{ text: "Old" }] }, videoCountShortText: { simpleText: "1,204" } } } + ] }); + if (b.continuation) return ok({ onResponseReceivedActions: [{ appendContinuationItemsAction: { continuationItems: [pvr(ID(9), "Last")] } }] }); + return ok({ contents: { tabs: [{ list: [pvr(ID(1), "First"), { continuationItemRenderer: { continuationEndpoint: { continuationCommand: { token: "TOK" } } } }] }] } }); + }; + const wl = await M.collection({ site: "youtube", kind: "watch_later" }, far()); + assert.deepStrictEqual(plain(wl.data), { site: "youtube", kind: "watch_later", cursor: "TOK", items: [{ videoId: ID(1), url: `https://www.youtube.com/watch?v=${ID(1)}`, title: "First", author: "Chan", duration: 125 }] }); + const next = await M.collection({ site: "youtube", kind: "watch_later", cursor: "TOK" }, far()); + assert.deepStrictEqual([next.data.items[0].videoId, next.data.cursor], [ID(9), ""]); + await M.collection({ site: "youtube", kind: "likes" }, far()); + await M.collection({ site: "youtube", kind: "playlist", id: "PLmine" }, far()); + const hist = await M.collection({ site: "youtube", kind: "history" }, far()); + assert.deepStrictEqual(plain(hist.data.items), [ + { videoId: ID(7), url: `https://www.youtube.com/watch?v=${ID(7)}`, title: "Seen", author: "Up", duration: 3723 }, + { videoId: ID(8), url: `https://www.youtube.com/watch?v=${ID(8)}`, title: "New look", author: "Chan B", duration: 0 } + ], "videoRenderer and the newer lockupViewModel; a playlist lockup is not a video"); + const pl = await M.collection({ site: "youtube", kind: "playlists" }, far()); + assert.deepStrictEqual(plain(pl.data.items), [{ id: "PLmine", title: "Mine" }, { id: "PLold", title: "Old", count: 1204 }]); + const subs = await M.collection({ site: "youtube", kind: "subscriptions" }, far()); + assert.deepStrictEqual(plain(subs.data.items), [{ channelId: "UCa", url: "https://www.youtube.com/channel/UCa", title: "Chan A" }]); + assert.deepStrictEqual(pages.map((b) => b.browseId || `cont:${b.continuation}`), ["VLWL", "cont:TOK", "VLLL", "VLPLmine", "FEhistory", "FEplaylist_aggregation", "FEchannels"]); + assert.strictEqual((await M.collection({ site: "youtube", kind: "playlist", id: "../x" }, far())).ok, false, "a bad playlist id is refused"); + yt = () => ({ status: 429, url: "", json: null }); + const t = await M.collection({ site: "youtube", kind: "watch_later" }, far()); + assert.deepStrictEqual([t.ok, t.code], [false, "THROTTLED"]); +}); + +check("calls run one at a time, a second apart", async () => { + maxInFlight = 0; + yt = (e, b) => (e === "browse" ? ok({}) : fakeYt(e, b)); + const both = await Promise.all([write({ op: "add", kind: "watch_later", items: [ID(2), ID(3)], dryRun: true }), M.collection({ site: "youtube", kind: "subscriptions" }, far())]); + assert.deepStrictEqual([both[0].ok, both[1].ok], [true, true]); + assert.strictEqual(maxInFlight, 1); + assert.ok(gaps.includes(1000), "the second call waited for the gap"); +}); + +(async () => { + let n = 0; + for (const [name, fn] of cases) { + reset(); + try { + await fn(); + console.log(`ok ${name}`); + n++; + } catch (e) { + console.error(`FAIL ${name}`); + console.error(e); + process.exit(1); + } + } + console.log(`agent-collections selftest: all ${n} passed`); +})(); diff --git a/extension/agent-mydata.js b/extension/agent-mydata.js index d7725764..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 设置 → 高级 → 允许 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; @@ -10,7 +10,7 @@ const HISTORY_PAGE = 30; const MAX_PAGE = 1000; - const fail = (code, message) => Object.assign(new Error(message), code ? { code } : {}); + const fail = (message, code) => Object.assign(new Error(message), code ? { code } : {}); const pageOf = (page) => (Number.isInteger(page) && page >= 1 ? Math.min(page, MAX_PAGE) : 1); const slice = (list, page, size) => ({ total: list.length, page, hasMore: page * size < list.length, items: list.slice((page - 1) * size, page * size) }); // Every word of the query in one of the fields (case-insensitive). @@ -55,7 +55,7 @@ return { folders: folders.map((f) => ({ id: String(f.id), title: f.title || "", count: Number(f.count) || 0 })) }; } const id = String(folderId); - if (!triageFolderId(id)) throw fail("", "folderId must be a folder id from the folder list, or toview."); + if (!triageFolderId(id)) throw fail("folderId must be a folder id from the folder list, or toview.", "BAD_PARAMS"); const pn = pageOf(page); const q = typeof query === "string" ? query.trim() : ""; let all = null; @@ -132,7 +132,7 @@ let at = ""; if (cursor) { const m = /^(\d+):(\d+):([a-z-]*)$/.exec(String(cursor)); - if (!m) throw fail("", "cursor must be the cursor a previous get_history returned."); + if (!m) throw fail("cursor must be the cursor a previous get_history returned.", "BAD_PARAMS"); at = `&max=${m[1]}&view_at=${m[2]}&business=${m[3]}`; } const data = await triageBiliGet(`${API}/x/web-interface/history/cursor?ps=${HISTORY_PAGE}${at}`); @@ -157,9 +157,6 @@ } const METHODS = { favorites, follows, history }; - const on = async () => MdgSettings.normalizeSwitches({ mcpPersonalData: (await chrome.storage.sync.get("mcpPersonalData")).mcpPersonalData }).mcpPersonalData; - const OFF = - "The user has not allowed AI tools to read their Bilibili favorites, follows and history. Ask the user to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史 (Settings → Advanced) in MoonDigest, then call again."; // One call at a time, GAP_MS apart: B站 rate-limits bursts. deadline: as agent-bridge.js handle. const GAP_MS = 1000; let queue = Promise.resolve(); @@ -168,8 +165,9 @@ const call = queue.then(async () => { const wait = lastAt + GAP_MS - Date.now(); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); - if (Date.now() > deadline) throw fail("TIMEOUT", "The call waited too long behind earlier ones."); - if (!(await on())) throw fail("PERSONAL_DATA_OFF", OFF); + if (Date.now() > deadline) throw fail("The call waited too long behind earlier ones.", "TIMEOUT"); + const off = await MdgAgentBridge.switchOff("mcpPersonalData"); + if (off) throw off; try { return await fn(params && typeof params === "object" ? params : {}); } finally { diff --git a/extension/agent-mydata.selftest.js b/extension/agent-mydata.selftest.js index 5457c34e..fab7def5 100644 --- a/extension/agent-mydata.selftest.js +++ b/extension/agent-mydata.selftest.js @@ -14,7 +14,8 @@ const order = [...fs.readFileSync(path.join(__dirname, "background.js"), "utf8") assert.ok(order.indexOf("agent-mydata.js") > order.indexOf("triage/follow-core.js"), "agent-mydata.js loads after the triage helpers it uses"); let M = null; for (const file of order) { - if (file === "agent-mydata.js") ctx.MdgAgentBridge = { addMethods: (m) => (M = m) }; + if (file === "agent-mydata.js") ctx.MdgAgentBridge = { switchOff: ctx.MdgAgentBridge.switchOff, addMethods: (m) => (M ??= m) }; + if (file === "agent-writes.js") ctx.MdgAgentBridge = { switchOff: ctx.MdgAgentBridge.switchOff, addMethods() {} }; // agent-writes.selftest.js's vm.runInContext(fs.readFileSync(path.join(__dirname, file), "utf8"), ctx); } assert.deepStrictEqual(Object.keys(M), ["favorites", "follows", "history"]); @@ -97,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 设置 → 高级 → 允许 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-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..bff9f157 --- /dev/null +++ b/extension/agent-sites/bilibili.js @@ -0,0 +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. 稍后再看 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 new file mode 100644 index 00000000..156548bd --- /dev/null +++ b/extension/agent-sites/bilibili.selftest.js @@ -0,0 +1,68 @@ +// 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); +// m.bilibili.com and the bare domain load as www (the content script runs there); b23.tv is left alone. +assert.strictEqual(S.canonical("https://m.bilibili.com/video/BV1xx411c7mD?p=2"), "https://www.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"); + +// 稍后再看 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/agent-sites/registry.js b/extension/agent-sites/registry.js new file mode 100644 index 00000000..3590a5de --- /dev/null +++ b/extension/agent-sites/registry.js @@ -0,0 +1,43 @@ +// 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"; + 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; + } + + // The page to load for href: a site's own form of it (adapter canonical(URL): youtu.be → its watch page), else href. + // Only for a requested URL: tabs keep their own URLs, since the content script runs on the canonical pages only. + function canonical(href) { + let u; + try { u = new URL(href); } catch { return href; } + for (const s of sites.values()) { + const c = s.canonical?.(u); + if (c) return c; + } + return href; + } + + const get = (id) => sites.get(id) || null; + const list = () => [...sites.values()]; + + globalThis.MdgAgentSites = { register, keyOf, canonical, get, list }; +})(); diff --git a/extension/agent-sites/registry.selftest.js b/extension/agent-sites/registry.selftest.js new file mode 100644 index 00000000..f7e273b8 --- /dev/null +++ b/extension/agent-sites/registry.selftest.js @@ -0,0 +1,36 @@ +// 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); + assert.strictEqual(S.keyOf("https://demo.test/abc"), "demo:abc:"); + assert.strictEqual(S.keyOf("https://other.test/abc"), null); + + 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..344d3e5b --- /dev/null +++ b/extension/agent-sites/youtube.js @@ -0,0 +1,206 @@ +// YouTube for the agent interface (docs/AGENT.md §5). The content script (agent-page.js) reads its watch pages; the +// user's collections (list / has / write) are youtubei calls made in the bridge's own tab, in a youtube.com page's own +// world, the way YouTube's web client makes them (ytApi). A service worker fetch cannot: it sends the extension's Origin, +// and Origin cannot be set from fetch. +(() => { + const HOME = "https://www.youtube.com/"; + const VIDEO_ID = /^[\w-]{11}$/; + const PLAYLIST_ID = /^[\w-]{2,64}$/; + const fail = (message, code) => Object.assign(new Error(message), code ? { code } : {}); + + // --- in the page (serialized by chrome.scripting: no closures, no extension globals) -------------------------------- + + const ytReady = () => (window.ytcfg?.get?.("INNERTUBE_CONTEXT") ? { ok: true } : undefined); + + // One youtubei POST with the headers yt-dlp's generate_api_headers and _get_sid_authorization_header build: + // SAPISIDHASH = `${ts}_${sha1(`${userSessionId} ${ts} ${SAPISID} ${origin}`)}_u` ("u" parts only with a user session id). + // → { status, url, json } or { status: 0, error }; { status: 401, loggedOut } without a login. + async function ytApi(endpoint, body, ms = 10_000) { + try { + const cfg = window.ytcfg; + const cookie = (n) => document.cookie.split("; ").find((c) => c.startsWith(`${n}=`))?.slice(n.length + 1); + const sids = [["SAPISIDHASH", cookie("SAPISID") || cookie("__Secure-3PAPISID")], ["SAPISID1PHASH", cookie("__Secure-1PAPISID")], ["SAPISID3PHASH", cookie("__Secure-3PAPISID")]]; + if (!cfg.get("LOGGED_IN") || !sids.some(([, sid]) => sid)) return { status: 401, loggedOut: true }; + // DATASYNC_ID is "DELEGATED||USER", or "USER||" without a brand account. + const [a = "", b = ""] = String(cfg.get("DATASYNC_ID") || "").split("||"); + const user = cfg.get("USER_SESSION_ID") || b || a; + const delegated = cfg.get("DELEGATED_SESSION_ID") || (b ? a : ""); + const ts = String(Math.round(Date.now() / 1000)); + const auth = []; + for (const [scheme, sid] of sids) { + if (!sid) continue; + const bytes = await crypto.subtle.digest("SHA-1", new TextEncoder().encode([...(user ? [user] : []), ts, sid, location.origin].join(" "))); + const hash = Array.from(new Uint8Array(bytes), (x) => x.toString(16).padStart(2, "0")).join(""); + auth.push(`${scheme} ${[ts, hash, ...(user ? ["u"] : [])].join("_")}`); + } + const context = cfg.get("INNERTUBE_CONTEXT"); + const headers = { + "Content-Type": "application/json", + Authorization: auth.join(" "), + "X-Origin": location.origin, + "X-Goog-AuthUser": String(cfg.get("SESSION_INDEX") ?? 0), + "X-Youtube-Client-Name": String(cfg.get("INNERTUBE_CONTEXT_CLIENT_NAME") ?? 1), + "X-Youtube-Client-Version": String(context.client?.clientVersion || ""), + "X-Youtube-Bootstrap-Logged-In": "true" + }; + if (delegated) headers["X-Goog-PageId"] = delegated; + if (context.client?.visitorData) headers["X-Goog-Visitor-Id"] = context.client.visitorData; + const res = await fetch(`${location.origin}/youtubei/v1/${endpoint}?prettyPrint=false`, { + method: "POST", credentials: "include", headers, body: JSON.stringify({ context, ...body }), signal: AbortSignal.timeout(ms) + }); + let json = null; + try { json = JSON.parse(await res.text()); } catch {} + return { status: res.status, url: res.url, json }; + } catch (e) { + return { status: 0, error: String(e?.message || e) }; + } + } + + // --- in the worker ------------------------------------------------------------------------------------------------- + + // ytApi's answer → its JSON, or a coded error: 429 and Google's /sorry/ captcha page are THROTTLED; 401 and 403 (the + // login or the SAPISIDHASH signature not accepted) NOT_LOGGED_IN. Both stop a batch. + function checked(d) { + if (!d || d.status === 0) throw fail(`YouTube did not answer${d?.error ? `: ${d.error}` : ""}.`, "NETWORK"); + if (d.status === 429 || /\/sorry\//.test(d.url || "")) throw fail(`${MdgErrors.TEXT.ytThrottled} (HTTP ${d.status}).`, MdgErrors.CODE.THROTTLED); + if (d.status === 401 || d.loggedOut) throw fail("Not logged in to YouTube in that browser.", MdgErrors.CODE.NOT_LOGGED_IN); + if (d.status === 403) throw fail("YouTube refused the call (HTTP 403): the login in that browser was not accepted. Ask the user to reload YouTube or log in again.", MdgErrors.CODE.NOT_LOGGED_IN); + if (d.status < 200 || d.status >= 300 || !d.json) throw fail(`YouTube answered HTTP ${d.status}.`); + return d.json; + } + + async function api(endpoint, body, deadline) { + // Each request ends by the call's deadline (at most 10 s), so the server never gives up first. + const ms = Math.max(1000, Math.min(10_000, deadline - Date.now())); + const r = await MdgAgentBridge.inSitePage(MdgAgentSites.get("youtube"), HOME, ytReady, ytApi, [endpoint, body, ms], deadline); + if (!r.ok) throw fail(r.error, r.code); + return checked(r.data); + } + + const text = (t) => t?.simpleText ?? t?.content ?? (t?.runs || []).map((r) => r.text).join(""); + + // Every object under one of these keys, in document order, not looking inside a hit. Walking the whole answer + // instead of a fixed path survives YouTube moving its shelves and tabs around. + function find(node, keys, out = []) { + if (Array.isArray(node)) for (const x of node) find(x, keys, out); + else if (node && typeof node === "object") { + for (const [k, v] of Object.entries(node)) { + if (keys.includes(k) && v && typeof v === "object") out.push([k, v]); + else find(v, keys, out); + } + } + return out; + } + + const lockupMeta = (v) => v.metadata?.lockupMetadataViewModel || {}; + const lockupParts = (v) => (lockupMeta(v).metadata?.contentMetadataViewModel?.metadataRows || []).flatMap((r) => r.metadataParts || []).map((p) => text(p.text)); + + // Videos of a playlist (playlistVideoRenderer), history (videoRenderer, lockupViewModel). + function videos(json) { + return find(json, ["playlistVideoRenderer", "videoRenderer", "lockupViewModel"]).flatMap(([k, v]) => { + if (k === "lockupViewModel") { + if (v.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO" || !v.contentId) return []; + return [{ videoId: v.contentId, url: `https://www.youtube.com/watch?v=${v.contentId}`, title: text(lockupMeta(v).title), author: lockupParts(v)[0] || "", duration: 0 }]; + } + if (!v.videoId) return []; + return [{ + videoId: v.videoId, + url: `https://www.youtube.com/watch?v=${v.videoId}`, + title: text(v.title), + author: text(v.shortBylineText || v.ownerText || v.longBylineText), + duration: Number(v.lengthSeconds) || Math.max(0, MdgCommon.parseTimestamp(text(v.lengthText))) || 0, + ...(v.isPlayable === false && { unavailable: true }) + }]; + }); + } + + function playlists(json) { + return find(json, ["gridPlaylistRenderer", "playlistRenderer", "lockupViewModel"]).flatMap(([k, v]) => { + if (k === "lockupViewModel") { + return v.contentType === "LOCKUP_CONTENT_TYPE_PLAYLIST" && v.contentId ? [{ id: v.contentId, title: text(lockupMeta(v).title) }] : []; + } + const count = parseInt(String(v.videoCount ?? text(v.videoCountShortText || v.videoCountText)).replace(/\D/g, ""), 10); + return v.playlistId ? [{ id: v.playlistId, title: text(v.title), ...(Number.isFinite(count) && { count }) }] : []; + }); + } + + const channels = (json) => + 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. + // 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. + const saved = (json) => new Map(find(json, ["playlistAddToOptionRenderer"]).map(([, v]) => [v.playlistId, v.containsSelectedVideos === "ALL"])); + + // kind → browseId and parser; "playlist" takes the id. + const LISTS = { + watch_later: ["VLWL", videos], + playlist: [null, videos], + likes: ["VLLL", videos], + playlists: ["FEplaylist_aggregation", playlists], + subscriptions: ["FEchannels", channels], + history: ["FEhistory", videos] + }; + + const playlistOf = (kind, id) => { + if (kind === "watch_later") return "WL"; + const p = String(id ?? "").replace(/^VL/, ""); + if (!PLAYLIST_ID.test(p)) throw fail("id must be a playlist id (from list_collection kind playlists)."); + return p; + }; + + async function list({ kind, id, cursor }, deadline) { + const [browseId, parse] = LISTS[kind]; + const body = cursor ? { continuation: cursor } : { browseId: browseId || `VL${playlistOf(kind, id)}` }; + const json = await api("browse", body, deadline); + return { items: parse(json), cursor: cursorOf(json) }; + } + + async function has({ kind, id, item }, deadline) { + if (!VIDEO_ID.test(item)) throw fail(`${item} is not a YouTube video id.`); + const pl = playlistOf(kind, id); + const map = saved(await api("playlist/get_add_to_playlist", { videoIds: [item], excludeWatchLater: false }, deadline)); + if (!map.has(pl)) throw fail(`${pl} is not one of the user's own playlists.`); + return map.get(pl); + } + + 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 }; + // 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({ + 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; + }, + // youtu.be, /shorts/, /live/, m. and music.youtube.com: the watch page, where the content script runs. + canonical(u) { + const h = u.hostname.replace(/^(www|m|music)\./, ""); + const v = h === "youtu.be" ? u.pathname.split("/")[1] + : h !== "youtube.com" ? null + : u.pathname === "/watch" ? (u.hostname === "www.youtube.com" ? null : u.searchParams.get("v")) + : /^\/(?:shorts|live)\/([\w-]{11})\/?$/.exec(u.pathname)?.[1]; + return v ? `https://www.youtube.com/watch?v=${encodeURIComponent(v)}` : null; + }, + read: "content", + search: (query, page) => MdgAgentSearch.searchYt(query, page), + // Likes and subscriptions are read only: like/removelike and subscribe are other endpoints, not built. + collections: { watch_later: ["add", "remove"], playlist: ["add", "remove"], playlists: [], likes: [], subscriptions: [], history: [] }, + list, + has, + write, + test: { ytApi, ytReady, checked, videos, playlists, channels, cursorOf, saved } + }); +})(); diff --git a/extension/agent-sites/youtube.selftest.js b/extension/agent-sites/youtube.selftest.js new file mode 100644 index 00000000..72b18a56 --- /dev/null +++ b/extension/agent-sites/youtube.selftest.js @@ -0,0 +1,99 @@ +// node extension/agent-sites/youtube.selftest.js +const assert = require("assert"); +const crypto = require("crypto"); +const vm = require("vm"); +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); +// Other forms of a watch page are read as the watch page; the content script runs there only. +const canon = { + "https://youtu.be/dQw4w9WgXcQ?t=5": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/shorts/dQw4w9WgXcQ": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://m.youtube.com/watch?v=dQw4w9WgXcQ&feature=share": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=x": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/live/dQw4w9WgXcQ": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30": "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30", + "https://www.youtube.com/@chan": "https://www.youtube.com/@chan" +}; +for (const [href, to] of Object.entries(canon)) { + assert.strictEqual(S.canonical(href), to, href); + if (to.includes("watch")) assert.strictEqual(S.keyOf(S.canonical(href)), "youtube:dQw4w9WgXcQ:", href); +} +const yt = S.get("youtube"); +assert.strictEqual(yt.read, "content"); + +// ytReady and ytApi run as chrome.scripting runs them: from their source, in a page with nothing of the extension. +// A closure or an extension global would throw here. +const inPage = (fn, { cfg, cookie, answer }) => { + const sent = []; + const page = vm.createContext({ + window: { ytcfg: cfg && { get: (k) => cfg[k] } }, + document: { cookie }, + location: { origin: "https://www.youtube.com" }, + crypto: globalThis.crypto, TextEncoder, AbortSignal, Uint8Array, JSON, String, Array, Math, Date, + fetch: async (url, init) => (sent.push({ url, ...init, body: JSON.parse(init.body) }), answer()) + }); + return { run: (...args) => vm.runInContext(`(${fn.toString()})`, page)(...args), sent }; +}; +const context = { client: { clientName: "WEB", clientVersion: "2.20261101.00.00", visitorData: "VIS" } }; +const cfg = { INNERTUBE_CONTEXT: context, LOGGED_IN: true, SESSION_INDEX: 0, INNERTUBE_CONTEXT_CLIENT_NAME: 1, DATASYNC_ID: "DELEG||USER" }; +const plain = (x) => JSON.parse(JSON.stringify(x)); +const sha1 = (s) => crypto.createHash("sha1").update(s).digest("hex"); + +(async () => { + assert.strictEqual(inPage(yt.test.ytReady, { cfg: null }).run(), undefined, "not ready before ytcfg"); + assert.deepStrictEqual(plain(inPage(yt.test.ytReady, { cfg }).run()), { ok: true }); + + const p = inPage(yt.test.ytApi, { cfg, cookie: "PREF=x; SAPISID=sid0; __Secure-1PAPISID=sid1; __Secure-3PAPISID=sid3", answer: () => ({ status: 200, url: "https://www.youtube.com/youtubei/v1/browse", text: async () => '{"a":1}' }) }); + const before = Math.round(Date.now() / 1000); + const r = await p.run("browse", { browseId: "VLWL" }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(r)), { status: 200, url: "https://www.youtube.com/youtubei/v1/browse", json: { a: 1 } }); + const [req] = p.sent; + assert.strictEqual(req.url, "https://www.youtube.com/youtubei/v1/browse?prettyPrint=false"); + assert.strictEqual(req.method, "POST"); + assert.deepStrictEqual(req.body, { context, browseId: "VLWL" }, "the page's own client context"); + // yt-dlp's _make_sid_authorization: sha1("USER ts sid origin"), "ts_hash_u", one per cookie, space separated. + const ts = /SAPISIDHASH (\d+)_/.exec(req.headers.Authorization)[1]; + assert.ok(Math.abs(ts - before) <= 1); + const h = (sid) => `${ts}_${sha1(`USER ${ts} ${sid} https://www.youtube.com`)}_u`; + assert.strictEqual(req.headers.Authorization, `SAPISIDHASH ${h("sid0")} SAPISID1PHASH ${h("sid1")} SAPISID3PHASH ${h("sid3")}`); + const { Authorization, ...rest } = req.headers; + assert.deepStrictEqual(rest, { + "Content-Type": "application/json", "X-Origin": "https://www.youtube.com", "X-Goog-AuthUser": "0", "X-Youtube-Client-Name": "1", + "X-Youtube-Client-Version": "2.20261101.00.00", "X-Youtube-Bootstrap-Logged-In": "true", "X-Goog-PageId": "DELEG", "X-Goog-Visitor-Id": "VIS" + }); + + // No brand account ("USER||"): no PageId; only SAPISID: one hash. + const q = inPage(yt.test.ytApi, { cfg: { ...cfg, DATASYNC_ID: "USER||" }, cookie: "SAPISID=sid0", answer: () => ({ status: 200, url: "", text: async () => "{}" }) }); + await q.run("browse", {}); + assert.strictEqual(q.sent[0].headers["X-Goog-PageId"], undefined); + assert.match(q.sent[0].headers.Authorization, /^SAPISIDHASH \d+_[0-9a-f]{40}_u$/); + + // Logged out: nothing sent. A fetch that throws: status 0, never a throw (a throw would read as "not ready" and + // the write would be sent again). + for (const [c, cookie] of [[{ ...cfg, LOGGED_IN: false }, "SAPISID=s"], [cfg, "PREF=x"]]) { + const o = inPage(yt.test.ytApi, { cfg: c, cookie, answer: () => assert.fail("sent") }); + assert.deepStrictEqual(plain(await o.run("browse", {})), { status: 401, loggedOut: true }); + } + const t = inPage(yt.test.ytApi, { cfg, cookie: "SAPISID=s", answer: () => Promise.reject(new Error("net down")) }); + assert.deepStrictEqual(plain(await t.run("browse", {})), { status: 0, error: "net down" }); + const bad = inPage(yt.test.ytApi, { cfg, cookie: "SAPISID=s", answer: () => ({ status: 200, url: "", text: async () => "" }) }); + assert.strictEqual((await bad.run("browse", {})).json, null); + + // 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))); diff --git a/extension/agent-writes.js b/extension/agent-writes.js new file mode 100644 index 00000000..175e3759 --- /dev/null +++ b/extension/agent-writes.js @@ -0,0 +1,268 @@ +// MoonDigest's agent interface, docs/AGENT.md §2: what an AI tool may change in the 分拣台, off until 设置 → MCP → 允许 +// 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. +// - 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 = (message, code) => Object.assign(new Error(message), code ? { code } : {}); + const bad = (message) => fail(message, "BAD_PARAMS"); + 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); + // 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, MdgTagCore.TAG_NAME_MAX, `${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 (!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); + // 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 = []; + 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) { + 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; + kept.push([it.bvid, was, v]); + if (v) next[it.bvid] = v; + else delete next[it.bvid]; + } + 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 || ""; + 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 }; + 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); + 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] || []); + }); + } + } 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 = changedIds().length; + if (n || made.length) { + const entry = { id: MdgTagCore.newTagId("a"), at: now, n, changes, made }; + // 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)] }), ...(noUndo && { noUndo }) }; + } + + // 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 () => { + 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); + }); + } + 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_MAX} proposals already wait for the user in the 分拣台. Ask the user to go through them first.`, "PROPOSALS_FULL"); + return [...cur, p]; + }); + return { id: p.id, status: "proposed" }; + } + + // Like collectionWrite (agent-collections.js), a write needs the read switch too: the 设置 row is greyed out under it. + const method = (fn) => async (params) => { + try { + const off = await MdgAgentBridge.switchOff("mcpPersonalData", "mcpWrites"); + if (off) throw 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..5bea4c8f --- /dev/null +++ b/extension/agent-writes.selftest.js @@ -0,0 +1,286 @@ +// /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; +let switchOff = null; // the real bridge's, loaded earlier in the order +for (const file of order) { + if (file === "agent-writes.js") { + ctx.MdgAgentBridge = { switchOff, 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") { + switchOff = ctx.MdgAgentBridge.switchOff; + ctx.MdgAgentBridge = { switchOff, 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 = { mcpPersonalData: true, mcpWrites: true }; +}; +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", /允许 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); + assert.deepStrictEqual([r.ok, r.code], [false, code], name); + assert.match(r.error, words); + } + } + 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, 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 }] }, + { 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" }] }, + { 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" }] } + ]; + 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("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: [ + { 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 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; + // 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..a2c55715 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,10 +1,13 @@ // 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 -// 设置 → 高级 → 允许 MCP 连接. At the top level: the alarm that wakes it must find its listener. +// 设置 → MCP → 允许 MCP 连接. At the top level: the alarm that wakes it must find its listener. const mcpOn = (value) => MdgSettings.normalizeSwitches({ mcpEnabled: value }).mcpEnabled; MdgAgentBridge.start({ extensionVersion: chrome.runtime.getManifest().version, @@ -1297,6 +1300,10 @@ async function probeAiChatCompletion({ baseUrl, apiKey, model }) { 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 user's collections on the sites whose adapter has them (docs/AGENT.md §2 collection, collectionWrite). +importScripts("agent-collections.js"); +// Its 分拣台 writes go through triage-bg.js's helpers too. +importScripts("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/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/extension/entry-matrix.selftest.js b/extension/entry-matrix.selftest.js index 6ecf509e..75d2950c 100644 --- a/extension/entry-matrix.selftest.js +++ b/extension/entry-matrix.selftest.js @@ -132,6 +132,9 @@ const NOT_ENTRIES = { "obsidian.js": "classifies Obsidian errors (OBSIDIAN / OBSIDIAN_DOWN); shows none", "sites.js": "classifies B站 / YouTube reads (THROTTLED, the gone codes); shows none", "agent-search.js": "the MCP search (docs/AGENT.md §2): passes THROTTLED on to the agent; shows none", + "agent-bridge.js": "the MCP socket (docs/AGENT.md §2): passes a page read's THROTTLED on to the agent; shows none", + "agent-collections.js": "the MCP collection tools (docs/AGENT.md §2): stops a write batch on THROTTLED / NOT_LOGGED_IN and tells the agent; shows none", + "agent-sites/youtube.js": "classifies youtubei answers for the MCP collection tools (THROTTLED, NOT_LOGGED_IN); shows none", "triage/triage-bg.js": "classifies AI and B站 errors for 分拣台 and 关注; shows none", "triage/runs.js": "the run judge: waits on AI_THROTTLED, stops on aiEnds (MdgErrors.action); shows none", "note.js": "writes subtitleMissing into a note (text, no remedy)", diff --git a/extension/limits.js b/extension/limits.js index 842de664..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. @@ -140,6 +146,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 @@ -154,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); @@ -378,6 +386,7 @@ storageKeys, migrateRenamedLocalStorage, locked, + keyLock, update, saveVideoNote, updateConversations, 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/load-order.selftest.js b/extension/load-order.selftest.js index b1ebec3d..f67c12d7 100644 --- a/extension/load-order.selftest.js +++ b/extension/load-order.selftest.js @@ -26,7 +26,8 @@ assert.ok(runtimes.length >= 11, `found ${runtimes.length} runtimes`); // The worker loads triage/shared.js for its pure helpers; the DOM bindings that read MdgTyping never run there. // The video page passes its own dialog to note-actions.js and only writes: its download and default dialogs never run there. -const exempt = new Set(["background.js|triage/shared.js|MdgTyping", "content.js|note-actions.js|MdgDownload", "content.js|note-actions.js|MdgOverwriteDialog"]); +// The bridge names MdgAgentPage only in a function it runs in a page, right after injecting agent-page.js there. +const exempt = new Set(["background.js|triage/shared.js|MdgTyping", "content.js|note-actions.js|MdgDownload", "content.js|note-actions.js|MdgOverwriteDialog", "background.js|agent-bridge.js|MdgAgentPage"]); let checked = 0; for (const [where, list, page] of runtimes) { // A script in the page's own world would hand the page our globals. 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.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 a57ed123..1c14d471 100644 --- a/extension/options.html +++ b/extension/options.html @@ -21,6 +21,7 @@排查问题时再开。
让 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 标签)、观看历史、稍后再看。只读,不改 B站 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。
Agent 还能通过 MCP 读你 B站的收藏夹和里面的视频(带分拣台的标签、备注和 AI 判断)、关注和分组(带 UP 标签)、观看历史、稍后再看,以及 YouTube 的稍后观看、赞过的视频、订阅、历史记录和 YouTube 播放列表。只读,不改 B站、YouTube 和 MoonDigest 里的任何东西;不含 API Key 和 AI 对话。要先打开「允许 MCP 连接」。
Agent 还能通过 MCP 写分拣台里的 AI 判断、保留、备注和视频标签,分拣台里按 U 撤销。取消收藏、移动和复制它只能提议,在分拣台里由你确认后才改 B站。要先打开「允许 Agent 读我的收藏、关注和历史」。
Agent 还能通过 MCP 把视频加进或移出你的 B站稍后再看、YouTube 稍后观看和你自己建的 YouTube 播放列表。一次不超过 20 个,一个一个改,改前改后各读一次,被限流就停。要先打开「允许 Agent 读我的收藏、关注和历史」。
排查问题时再开。
本页、收藏夹、关注和摘读记录的全部设置存成一个 JSON 文件,换电脑或换浏览器时导入。不含 Key 和分拣数据。
Agent 改了 ${e.n} 个视频的 AI 判断、保留、备注或标签,撤销后改回去;你之后又改过的不动。
`, "撤销"] : null, + undo: async () => { + const r = await send({ type: "agent-undo", id: e.id }); + 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() || `已撤销:Agent 的修改(${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) && !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)); + 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 取消收藏 / +// 移动 / 复制 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) { + 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 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. +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; + 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 = `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("已拒绝 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 } }); +} + // 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/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/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/tag-store.js b/extension/triage/tag-store.js index 21931e99..7a8aee15 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站 @@ -150,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()); @@ -177,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(";")})`; } }; } @@ -230,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 }; @@ -238,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; } @@ -329,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)); @@ -336,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 }; }); @@ -345,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}`); @@ -403,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 d71e8c52..6b76ec53 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); @@ -121,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 = []; @@ -215,6 +249,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..02bdfacd 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. @@ -1543,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; @@ -4566,6 +4572,138 @@ 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"], `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", "已撤销: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 + 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, "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)), ["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]], [], "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; + 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, "已拒绝 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"); + 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) => { console.error(e); diff --git a/mcp/README.md b/mcp/README.md index 3e7ba27e..3004068b 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,12 +26,22 @@ 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 秒;每个先读一次,已经是那样的跳过,改完再读一次确认(YouTube 以它自己的写入回应为准:它的状态要十来秒才更新)。被限流(`THROTTLED`)或掉了登录就停,返回改了哪些、哪些没动,和撤销用的那次调用。撤销不能完全复原:移出后再加回的视频排到列表末尾,原来的位置回不来;同一个视频在播放列表里出现多次时,移出会按视频 id 移走,撤销只加回一次。 + +改分拣台的两个工具,默认关闭,要在「允许 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`,和分拣台细看的结论一样显示,卡片上标「 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`。 ### 用 MCP 还是用 skill - **skill**(`skills/moondigest/`):agent 本来就在操控浏览器(能在页面里跑 JS)时用。不用开开关,不用装服务器。 -- **MCP**:任何 MCP 客户端都能用,agent 不需要会操控浏览器。代价是要装这个服务器,并在 MoonDigest 的「设置 → 高级 → 允许 MCP 连接」打开开关。 +- **MCP**:任何 MCP 客户端都能用,agent 不需要会操控浏览器。代价是要装这个服务器,并在 MoonDigest 的「设置 → MCP → 允许 MCP 连接」打开开关。 ### 安装 @@ -72,17 +82,19 @@ args = ["/path/to/MoonDigest/mcp/server.js"] 发布后把 `node` 和路径换成 `npx` 和 `["-y", "moondigest-mcp"]`。 -最后在 MoonDigest 的「设置 → 高级」打开「允许 MCP 连接」。 +最后在 MoonDigest 的「设置 → MCP」打开「允许 MCP 连接」。 ### 安全模型 - 只监听 `127.0.0.1`,局域网和外网连不上。 - 只接受 `Origin` 以 `chrome-extension://` 开头的连接。网页也能向 localhost 开 WebSocket,这一条把它们挡在外面(握手直接被拒)。但任何已装的扩展都能通过这一条:你装的别的扩展可以抢先连上,给 agent 回假数据。服务器信任你自己装的扩展。 - 同一时间只认一个扩展。第二个连上来会被关闭(关闭码 4001)。 -- 扩展只会打开 B站视频页(`/video/`、`/bangumi/play/`、`/list/`、`/festival/`)和 YouTube 观看页(`/watch?v=`),其他网址一律返回 `BAD_URL`。扩展最多开一个自己的后台标签页,之后的视频都在它里面打开。`get_video` 不会切换标签页。Chrome 不给从没显示过的标签页加载视频,所以 `get_frame` 第一次在这种标签页里截帧时,会把它切到前台截完,再切回你原来的标签页,屏幕会闪一下。 +- 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 对话不会给出去。 +- 改收藏再有一个开关,默认关闭,上面两个有一个关着它就变灰。B站稍后再看由扩展后台直接改,和分拣台改稍后再看是同一组请求。YouTube 的读和改都在扩展自己的后台标签页里、YouTube 网页自己的环境中发出,和 YouTube 网页自己发的请求一样,不碰你开着的标签页。 +- 改分拣台也另有一个开关,默认关闭,和改收藏一样,上面两个有一个关着它就变灰。打开后 Agent 只能直接改本地的分拣结果,每次都能在分拣台撤销;B站 上的改动只能提议,由你在分拣台确认,不靠 MCP 客户端的权限弹框。 ### 两个 MCP 客户端同时开着 @@ -104,8 +116,12 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - `TIMEOUT`:60 秒内没有回答。常见原因是页面加载太慢,或后台标签页被浏览器冻结。切到那个标签页看一眼再试。 - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 -- `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 -- `NOT_LOGGED_IN`:这个浏览器没登录 B站。 +- `PERSONAL_DATA_OFF`:「允许 Agent 读我的收藏、关注和历史」没开。 +- `COLLECTION_WRITES_OFF`:「允许 Agent 改我的收藏」没开。 +- `TRIAGE_WRITES_OFF`:「允许 Agent 改分拣台」没开。 +- `BAD_PARAMS`:参数不对,错误里写着哪一项;这次什么都没写。 +- `PROPOSALS_FULL`:已经有 20 条提议在分拣台等你处理。 +- `NOT_LOGGED_IN`:这个浏览器没登录 B站(收藏工具:没登录 YouTube)。 ### 测试 @@ -129,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. @@ -137,12 +153,22 @@ 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` `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; 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`. + +- `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. ### 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 @@ -183,17 +209,19 @@ 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 - Listens on `127.0.0.1` only. Nothing on the network can reach it. - Accepts a connection only when its `Origin` starts with `chrome-extension://`. Web pages can open WebSockets to localhost too. This check rejects them at the handshake. Any installed extension passes it, though: another extension you installed could connect first and answer with made-up data. The server trusts the extensions you installed. - One extension at a time. A second one is closed with code 4001. -- 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. +- Bilibili video pages (`/video/`, `/bangumi/play/`, `/list/`, `/festival/`) and YouTube watch pages (`/watch?v=`) may be read in a tab you already have open. 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. 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 @@ -215,8 +243,12 @@ 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. -- `NOT_LOGGED_IN`: that browser is not logged in to Bilibili. +- `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). ### Tests diff --git a/mcp/server.js b/mcp/server.js index 5f8371c2..4566646c 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; @@ -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) => { @@ -204,6 +204,17 @@ 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(", ")}.` : ""; + 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) { + 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) { @@ -213,6 +224,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=…)."; @@ -225,7 +263,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." }, }, @@ -293,13 +331,95 @@ const TOOLS = [ }, }, }, + { + name: "list_collection", + 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: "\"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." }, + 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 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: "\"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: 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." }, + }, + required: ["site", "op", "kind", "items"], + }, + }, + { + 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 PERSONAL_DATA_OFF or TRIAGE_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 PERSONAL_DATA_OFF or TRIAGE_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], + list_collection: ["collection", ["site", "kind", "id", "cursor"], formatCollection], + 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 +428,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 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"); function errorResult(err) { @@ -325,18 +447,25 @@ 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 }); 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]]))); 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 ccff8192..24427a1b 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", "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/); @@ -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(); @@ -296,3 +310,73 @@ 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; COLLECTION_WRITES_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: "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, "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 } }); + ext.ws.close(); + await s.close(); +}); + +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: "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); + 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, "TRIAGE_WRITES_OFF: Ask the user to turn it on."); + ext.ws.close(); + await s.close(); +}); 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) {