diff --git a/PRIVACY.md b/PRIVACY.md index 897e434c..d19afe7e 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 视频页的请求。见 README 的「给 AI agent 用」。 +- 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站历史记录:只在你打开分拣台或 B站页面时读,最多 10 分钟一次,只读上次之后新增的部分。每个视频只在本地存两个数:看到百分之多少、最后观看时间,留 1 年、最多 5000 个(勾选收藏夹里的视频不删)。这些数据不发往任何地方。选「关」就不读。 diff --git a/docs/AGENT.md b/docs/AGENT.md index 771ec614..25b6e34a 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -1,6 +1,6 @@ # MoonDigest agent interface v1 -How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client. Read-only: no keys, notes, conversations or 分拣 results. +How AI agents read a video through MoonDigest: page messages for agents that drive the browser (the skill, `skills/moondigest/SKILL.md`), and a local MCP server (`mcp/`) for any MCP client, which can also search for videos (§2 `search`). The page messages cannot search. Read-only. Never API keys or AI conversations. The user's own Bilibili favorites, follows and watch history, with their 分拣 tags, notes and AI verdicts, only through the MCP server and only while the user allows it (§2, `favorites` / `follows` / `history`). Supported pages: Bilibili video pages (`www.bilibili.com/video/…`, `/bangumi/play/…`, `/list/…`, `/festival/…`) and YouTube watch pages (`www.youtube.com/watch?v=…`). @@ -64,19 +64,35 @@ Server → extension: `{ id, method, params }`. Extension → server: `{ id, ok: |---|---|---| | `get` | `{ url }` | Same `data` as `moondigest-get`, for the video at `url` | | `frame` | `{ url, at, maxWidth? }` | Same `data` as `moondigest-frame` | +| `search` | `{ site, query, page? }` | A video search on `site` (`"bilibili"` \| `"youtube"`), `page` default 1; see below | +| `favorites` | `{ folderId?, page?, query? }` | The user's Bilibili favorites, see below | +| `follows` | `{ page?, query? }` | The UP 主 the user follows, see below | +| `history` | `{ cursor? }` | The user's Bilibili watch history, see below | -Both find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. +`get` and `frame` find a tab already showing that video (same site and video id; Bilibili `/video/` also the part `p`, default 1; `/list/` and `/festival/` match on the `bvid` query, else the path; `/bangumi/` on its ep/ss id). A discarded tab (no content script until shown) is never picked. Else they load `url` in the extension's own tab: at most one, opened muted with `active: false` on first need and navigated to the next video afterwards, so calls never pile up tabs. Calls that need it run one at a time, and a call still queued when the server has given up (55 s after it arrived) is dropped with `TIMEOUT` instead of loading anything. A tab that no call has answered in yet counts as the requested video only while it is still loading. The extension waits up to 50 s for that tab's content script to answer (a missing reply or one without `ok` means not loaded yet), then fails with `TIMEOUT`, before the server's own 60 s. The extension rejects any `url` that is not a supported page with code `BAD_URL`. Then they run the same handler the page messages run, in that tab's content script (`chrome.tabs.sendMessage`), so both paths return identical results. A `frame` that comes back `TAB_HIDDEN` (Chrome loads no media in a tab never shown) is asked again with that tab in front, and the tab that was in front of its window is brought back afterwards, unless the user switched tabs meanwhile; `get` never switches tabs. Timeout: 60 s per call, code `TIMEOUT`. + +`search` opens no tab: the service worker fetches with the user's cookies. Bilibili: the WBI-signed `api.bilibili.com/x/web-interface/wbi/search/type?search_type=video` through the same channel as 分拣台 (`triageBiliGet`). YouTube: the `www.youtube.com/results?search_query=…` page, read from its `ytInitialData`; only the result list's own videos, no shorts, ads, channels, playlists or shelves. YouTube has page 1 only (about 20 videos); a `page` above 1 is an error. Searches run one at a time, at least 2 s apart; one still queued at the call's deadline is dropped with `TIMEOUT`. HTTP 412, B站's risk-control codes and YouTube's 429 are `THROTTLED`. No results is `items: []`, not an error. + +`data`: `{ site, query, page, items }`, each item `{ site, videoId, url, title, author, duration, views, uploadDate, description }`: `url` is `https://www.bilibili.com/video/BV…` or `https://www.youtube.com/watch?v=…` (ready for `get`); `title`, `author`, `description` plain text (B站's `` highlights and HTML entities removed), `description` at most 200 characters; `duration` seconds, `0` when unknown (a live stream); `views` a number or `null`; `uploadDate` B站 `YYYY-MM-DD` (local day), YouTube as the page writes it ("2 years ago", in the browser's language), `""` when missing. + +`favorites`, `follows` and `history` (`extension/agent-mydata.js`) read the user's own Bilibili account in the service worker, no tab. Off by default: while 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史 (`mcpPersonalData`, under 允许 MCP 连接 and greyed out while that is off) is off they fail with `PERSONAL_DATA_OFF`. They only GET Bilibili's read endpoints (the 分拣台's channel, `triageBiliGet`) and write nothing, to Bilibili or to storage. They run one at a time, a second apart, and return one page per call. Bilibili is asked for one page, except the folder list (up to 20 pages of folders) and 稍后再看 (all of it in one request); a ticked folder's local copy is read whole, then paged. + +- `favorites` without `folderId`: `{ folders: [{ id, title, count }] }`, 稍后再看 first with id `"toview"`, then the user's own folders (Bilibili's folder list, read live). With `folderId`: `{ folderId, source, total, page, hasMore, items }`, 40 a page. A folder ticked in the 分拣台 is read from its local copy (`source: "local"`, as of the 分拣台's last read, no Bilibili request); `toview` is read whole from Bilibili in one request; any other folder is one Bilibili page (`source: "bilibili"`). `query`: words that must all appear, case-insensitive, matched on title, author, tags, note and summary for the local copy and `toview`; for other folders Bilibili's own favorites search gets it as `keyword`. An item: `{ bvid, url, title, author, duration, favTime, invalid?, progress?, tags?, note?, verdict?, summary?, kept? }`: `tags` are the user's 分拣 tags (and 摘读记录 tags), `note` the 备注, `verdict` `keep` / `drop` / `unsure` (`MdgTagCore.verdictOf`), `summary` the 细看's one-liner, `kept` the 保留 mark, `progress` seconds watched in 稍后再看 (-1 finished). +- `follows`: `{ source, total, page, hasMore, items: [{ mid, name, sign, groups, tags? }] }`, 50 a page. With a synced 关注 list (the 分拣台's 关注) it is read from there (`source: "local"`, with the user's UP 标签 as `tags`, and `query` matched on name, sign, groups and tags); else one Bilibili page of the followings plus the group names (`source: "bilibili"`, `query` is not applied and `queryIgnored: true`). `groups` are 关注分组 names, 特别关注 and 默认分组 included. +- `history`: `{ items: [{ bvid?, url, title, author, viewAt, progress, duration, kind }], cursor }`, 30 a call, newest first. `cursor` is opaque; pass it back for the next page, `""` at the end. `kind` is Bilibili's business (`archive` for a video, also `pgc`, `live`, `article`…); `progress` is seconds, -1 finished; `viewAt` seconds since the epoch. Keepalive: the server sends `{ type: "ping" }` every 20 s and the extension answers `{ type: "pong" }` (WebSocket traffic keeps an MV3 service worker alive in Chrome 116+). ## 3. Code - `extension/agent-page.js` (content script): `MdgAgentPage.bind({ read, signal })`. `read()` is content.js's `agentRead`, the side panel's read of the page's video, resolving to §1's `get` data or rejecting with an `Error` carrying `code`. It answers the page messages and the bridge's `chrome.runtime` messages `{ type: "agent-call", method, params }` (`params` without `url`) with `{ ok, data }` or `{ ok: false, error, code }`, and runs `frame` itself. -- `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling. +- `extension/agent-bridge.js` (service worker): `MdgAgentBridge.start({ extensionVersion, isEnabled, onEnabledChange })`, §2's socket and tab handling. `MdgAgentBridge.addMethods({ name: (params) => reply })` adds methods answered without a tab. +- `extension/agent-search.js` (service worker): `MdgAgentSearch.search(params, deadline)`, §2's `search`. +- `extension/agent-mydata.js` (service worker, loaded after the 分拣台's `triage-bg.js` and `follow-core.js`): §2's `favorites`, `follows`, `history`. - `mcp/server.js`: the MCP server, `npm test` in `mcp/`. ## 4. Codes -`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). +`NOT_VIDEO_PAGE`, `TAB_HIDDEN`, `THROTTLED` (Bilibili 412 / YouTube 429: stop, do not retry soon), `NO_VIDEO`, `SEEK_TIMEOUT`, `AD_PLAYING`, `FRAME_BLOCKED`, `BAD_URL`, `TIMEOUT`, `NOT_CONNECTED` (MCP server: no extension connected), `PERSONAL_DATA_OFF` (the user has not allowed `favorites` / `follows` / `history`: ask them to turn on 设置 → 高级 → 允许 AI 工具读我的收藏、关注和历史), `NOT_LOGGED_IN` (not logged in to Bilibili in that browser), `NETWORK` (Bilibili did not answer), `PORT_IN_USE` (MCP server: another moondigest-mcp holds the port; it takes over when that one exits). The port is fixed at 47823 on both sides. The server's `MOONDIGEST_MCP_PORT` is for tests only. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0c0b4039..229b74e5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -342,6 +342,7 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准 - 说明文字不要散在外面。改成按钮后面加一个琥珀色小点,点开以后说明写在里面,通常放进输入框的 placeholder。 - 设置页「AI 平台」的「视频」「网页」是页签,照 B站 空间页的页签:16px,当前的加粗、下面 3px 粉线,旁边小灰字写用在哪(「B站 · YouTube」「其他网站」),一次只显示一组;左右方向键切换。每次打开设置页在「视频」。为什么:两组的项同名,摆在一起分不清在改哪一组(2026-10-22 用户定)。 +- 设置页里只在另一个开关开着时才起作用的开关(「允许 AI 工具读我的收藏、关注和历史」之于「允许 MCP 连接」)放在那一行下面,左边缩进 24px,两行之间没有分隔线。上面那个关着时它变灰、不能点,悬停写「先打开「允许 MCP 连接」」,说明文字里也写这一句;它自己的开关状态照存,上面那个再打开时恢复原样,不自动打开也不清掉。为什么:看得出从属关系,又不让关掉上层就丢了下层的选择(2026-11-06 用户定)。 #### 定下来的决定(改之前先问用户) diff --git a/extension/agent-bridge.js b/extension/agent-bridge.js index 7f948a96..0f04ddf4 100644 --- a/extension/agent-bridge.js +++ b/extension/agent-bridge.js @@ -138,7 +138,13 @@ // deadline: when the server stops waiting for this call (its 60 s TIMEOUT). A call still queued then is dropped, so // nothing loads a video or asks the site for a call nobody waits for any more. + // Methods other files add (agent-mydata.js): each takes params and the deadline and resolves to a reply, without a tab. + const added = {}; + const addMethods = (methods) => Object.assign(added, methods); + async function handle(method, params, deadline = Date.now() + CALL_BUDGET_MS) { + 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; @@ -242,5 +248,5 @@ Promise.resolve(o.isEnabled()).then(apply, () => apply(false)); } - globalThis.MdgAgentBridge = { start }; + globalThis.MdgAgentBridge = { start, addMethods }; })(); diff --git a/extension/agent-bridge.selftest.js b/extension/agent-bridge.selftest.js index 19b7ee4b..1fc979fb 100644 --- a/extension/agent-bridge.selftest.js +++ b/extension/agent-bridge.selftest.js @@ -80,6 +80,7 @@ function makeEnv({ enabled, tabs = [], session = {}, answer = null, commitNow = onEnabledChange: (cb) => { env.enabledCb = cb; }, }); env.tabs = tabs; + env.addMethods = (m) => ctx.MdgAgentBridge.addMethods(m); return env; } @@ -139,6 +140,17 @@ check("BAD_URL for unsupported pages", async () => { assert.equal(env.sent.length, 0); }); +check("an added method answers without a tab, before the url check", async () => { + const env = makeEnv({ enabled: true }); + env.addMethods({ favorites: async (p) => ({ ok: true, data: { got: p } }), history: async () => ({ ok: false, code: "PERSONAL_DATA_OFF", error: "off" }) }); + const s = await openSocket(env); + assert.deepEqual(await rpc(s, "m1", "favorites", { folderId: "11" }), { id: "m1", ok: true, data: { got: { folderId: "11" } } }); + assert.deepEqual(await rpc(s, "m2", "history", {}), { id: "m2", ok: false, error: "off", code: "PERSONAL_DATA_OFF" }); + assert.equal((await rpc(s, "m3", "toString", {})).error, "Unknown method toString."); + assert.equal(env.created.length, 0); + assert.equal(env.sent.length, 0); +}); + check("reuses a tab showing the same video", async () => { const env = makeEnv({ enabled: true, tabs: [{ id: 7, url: "https://www.youtube.com/watch?v=abc&t=30" }] }); const s = await openSocket(env); diff --git a/extension/agent-mydata.js b/extension/agent-mydata.js new file mode 100644 index 00000000..d7725764 --- /dev/null +++ b/extension/agent-mydata.js @@ -0,0 +1,187 @@ +// 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). +// The worker loads it after triage-bg.js and follow-core.js, whose helpers it uses. +(() => { + const K = MdgLimits.KEYS; + const API = "https://api.bilibili.com"; + const FAV_PAGE = TriageUi.FAV_PAGE_SIZE; // 40: B站's own favorites page; local lists page the same + const FOLLOW_PAGE = 50; + const HISTORY_PAGE = 30; + const MAX_PAGE = 1000; + + const fail = (code, message) => 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). + const matches = (query, fields) => { + const hay = fields.filter(Boolean).join("\n").toLowerCase(); + return String(query).toLowerCase().split(/\s+/).filter(Boolean).every((w) => hay.includes(w)); + }; + + // The 分拣台's data on each video: tags (and 摘读记录's), 备注, the AI verdict and one-line summary, 保留. + async function withTriage(items) { + const bvids = items.map((it) => it.bvid); + const keys = [K.triageNotes, K.triageTags, K.triageVideoTags, K.readingMarks, K.triageKept, ...bvids.flatMap((b) => [K.triageAnalysis(b), K.triageTitle(b)])]; + const got = await chrome.storage.local.get(keys); + return items.map((it) => { + const x = MdgNote.triageExtras(got, { site: "bilibili", videoId: it.bvid }); + const v = MdgTagCore.verdictOf({ title: got[K.triageTitle(it.bvid)], analysis: x.analysis, invalid: it.invalid }); + const summary = x.analysis?.status === "done" ? String(x.analysis.oneLiner || "") : ""; + return { + bvid: it.bvid, + url: TriageUi.videoUrl(it.bvid), + title: it.title || "", + author: it.upper || "", + duration: Number(it.duration) || 0, + favTime: Number(it.favTime) || 0, + ...(it.invalid && { invalid: true }), + ...(it.seen != null && it.seen !== 0 && { progress: it.seen }), + ...(x.userTags.length && { tags: x.userTags }), + ...(x.note && { note: x.note }), + ...(v.verdict !== "none" && { verdict: v.verdict }), + ...(summary && { summary }), + ...(got[K.triageKept]?.[it.bvid] && { kept: true }) + }; + }); + } + + // No folderId: the folders, 稍后再看 first (id "toview"). A folder the 分拣台 has (ticked, so a local copy) and + // 稍后再看 (one request for all of it) are searched by query on every field; another folder is read a page at a time + // from B站, which searches its own titles. + async function favorites({ folderId, page, query }) { + if (folderId == null || folderId === "") { + const { folders } = await triageCreatedFolders(); + 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."); + const pn = pageOf(page); + const q = typeof query === "string" ? query.trim() : ""; + let all = null; + let source = "local"; + if (id === TRIAGE_TOVIEW) { + all = (await triageToviewList()).map(triageToviewItem); + source = "bilibili"; + } else { + const got = await chrome.storage.local.get([K.triageIncludedFolders, K.triageSnapshot(id)]); + const ticked = (got[K.triageIncludedFolders] || []).some((f) => String(f) === id); + all = (ticked && got[K.triageSnapshot(id)]?.items) || null; + } + if (all) { + if (!q) { + const one = slice(all, pn, FAV_PAGE); + return { folderId: id, source, ...one, items: await withTriage(one.items) }; + } + const rows = await withTriage(all); + const hit = rows.filter((r) => matches(q, [r.title, r.author, r.note, r.summary, ...(r.tags || [])])); + return { folderId: id, source, ...slice(hit, pn, FAV_PAGE) }; + } + const kw = q ? `&keyword=${encodeURIComponent(q)}&order=mtime&type=0` : ""; + const data = await triageBiliGet(`${API}/x/v3/fav/resource/list?media_id=${id}&ps=${FAV_PAGE}&pn=${pn}&platform=web${kw}`); + const items = await withTriage((data?.medias || []).map(triageFavItem).filter(Boolean)); + // With a keyword, media_count is still the whole folder's, not the hits: leave total out. + return { folderId: id, source: "bilibili", ...(!q && { total: Number(data?.info?.media_count) || 0 }), page: pn, hasMore: Boolean(data?.has_more), items }; + } + + const upRow = (mid, person, D) => ({ + mid: String(mid), + name: person?.name || "", + sign: person?.sign || "", + groups: FollowCore.groupNames(FollowCore.groupsOf(String(mid), D), D) + }); + + // The 关注 the 分拣台 synced (with UP 标签; query searches name, sign, groups and tags), else B站's list a page at a time. + async function follows({ page, query }) { + const pn = pageOf(page); + const q = typeof query === "string" ? query.trim() : ""; + const D = await MdgLimits.followGet(["list", "people", "groups", "tags", "tag_map"]); + if (D.list?.list?.length && !D.list.partial) { + const names = new Map((D.tags || []).map((t) => [t.id, t.name])); + const rows = D.list.list.map((mid) => { + const tags = FollowCore.liveTags(mid, { map: D.tag_map, tags: D.tags || [] }).map((t) => names.get(t)).filter(Boolean); + return { ...upRow(mid, D.people?.[mid], D), ...(tags.length && { tags }) }; + }); + const hit = q ? rows.filter((r) => matches(q, [r.name, r.sign, ...r.groups, ...(r.tags || [])])) : rows; + return { source: "local", ...slice(hit, pn, FOLLOW_PAGE) }; + } + const mid = await triageMid(); + const data = await triageBiliGet(`${API}/x/relation/followings?vmid=${mid}&pn=${pn}&ps=${FOLLOW_PAGE}&order=desc`); + const tags = await triageBiliGet(`${API}/x/relation/tags`); + const list = data?.list || []; + const B = { + list: { + special: Object.fromEntries(list.filter((x) => x.special === 1 || (x.tag || []).includes(TriageUi.FOLLOW_SPECIAL)).map((x) => [String(x.mid), 1])), + groups: Object.fromEntries(list.map((x) => [String(x.mid), (x.tag || []).filter((t) => t > 0)])) + }, + groups: (Array.isArray(tags) ? tags : []).filter((g) => g.tagid > 0).map((g) => ({ id: g.tagid, name: String(g.name || "") })) + }; + const total = Number(data?.total) || 0; + return { + source: "bilibili", + ...(q && { queryIgnored: true }), + total, + page: pn, + hasMore: pn * FOLLOW_PAGE < total, + items: list.map((x) => upRow(x.mid, { name: x.uname, sign: String(x.sign || "").slice(0, 60) }, B)) + }; + } + + // B站's watch history, newest first, HISTORY_PAGE a page; cursor is the previous page's, "" at the end. + async function history({ cursor }) { + 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."); + 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}`); + const list = data?.list || []; + const c = data?.cursor; + return { + items: list.map((it) => { + const bvid = it.history?.business === "archive" ? it.history.bvid || "" : ""; + return { + ...(bvid && { bvid }), + url: bvid ? TriageUi.videoUrl(bvid) : String(it.uri || ""), + title: [it.title, it.show_title].filter(Boolean).join(" "), + author: it.author_name || "", + viewAt: Number(it.view_at) || 0, + progress: Number(it.progress) || 0, + duration: Number(it.duration) || 0, + kind: it.history?.business || "" + }; + }), + cursor: list.length && c?.max ? `${c.max}:${c.view_at}:${c.business || ""}` : "" + }; + } + + 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(); + let lastAt = 0; + function run(fn, params, deadline) { + 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); + try { + return await fn(params && typeof params === "object" ? params : {}); + } 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(Object.fromEntries(Object.entries(METHODS).map(([name, fn]) => [name, (params, deadline) => run(fn, params, deadline)]))); +})(); diff --git a/extension/agent-mydata.selftest.js b/extension/agent-mydata.selftest.js new file mode 100644 index 00000000..5457c34e --- /dev/null +++ b/extension/agent-mydata.selftest.js @@ -0,0 +1,230 @@ +// /usr/local/bin/node extension/agent-mydata.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"); + +// The worker's load order, with the bridge's addMethods caught so the methods can be called directly. +// agent-mydata.js's 1 s gap between calls runs at once and is recorded; other timers stay real. +const gaps = []; +const fastGap = (fn, ms) => (ms > 0 && ms <= 1000 ? (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-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) }; + vm.runInContext(fs.readFileSync(path.join(__dirname, file), "utf8"), ctx); +} +assert.deepStrictEqual(Object.keys(M), ["favorites", "follows", "history"]); +const plain = (x) => JSON.parse(JSON.stringify(x)); +const K = ctx.MdgLimits.KEYS; + +// ---- fake chrome storage ---- +let local = {}; +let sync = {}; +const pick = (store, keys) => { + if (keys == null) return { ...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 writes = []; +ctx.chrome = { + storage: { + local: { get: async (k) => pick(local, k), set: async (o) => void writes.push(o), remove: async (k) => void writes.push(k) }, + sync: { get: async (k) => pick(sync, k), set: async (o) => void writes.push(o) } + }, + cookies: { get: async () => ({ value: "csrf" }) } +}; + +// ---- fake B站: every request is recorded; only GETs to these paths are answered ---- +const json = (body, status = 200) => ({ ok: status === 200, status, json: async () => body }); +const fetched = []; +let inFlight = 0; +let maxInFlight = 0; +let routes = {}; +ctx.fetch = async (url, init = {}) => { + fetched.push({ url, method: init.method || "GET", body: init.body }); + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight--; + const u = new URL(url); + const r = routes[u.pathname]; + if (!r) throw new Error(`no route ${u.pathname}`); + return r(u); +}; +const READS = new Set([ + "/x/web-interface/nav", "/x/v3/fav/folder/created/list", "/x/v2/history/toview/web", "/x/v2/history/toview", + "/x/v3/fav/resource/list", "/x/relation/followings", "/x/relation/tags", "/x/web-interface/history/cursor" +]); +const ok = (data) => () => json({ code: 0, data }); +const baseRoutes = () => ({ + "/x/web-interface/nav": ok({ isLogin: true, mid: 1000 }), + "/x/v3/fav/folder/created/list": ok({ list: [{ id: 11, title: "Rust", media_count: 2, cover: "http://c" }, { id: 22, title: "杂", media_count: 50, cover: "" }], has_more: false }), + "/x/v2/history/toview/web": ok({ count: 1, list: [{ pic: "http://p" }] }), + "/x/v2/history/toview": ok({ list: [{ bvid: "BV1toview001", aid: 5, title: "稍后 Rust", pic: "", owner: { name: "UPt", mid: 9 }, duration: 600, add_at: 1700000000, desc: "", state: 0, progress: 120 }] }), + "/x/v3/fav/resource/list": (u) => ok({ info: { media_count: 50 }, has_more: u.searchParams.get("pn") === "1", medias: [ + { type: 2, id: 77, bvid: "BV1remote001", title: "远程视频", upper: { name: "UPr", mid: 8 }, duration: 61, fav_time: 1700000100, attr: 0, intro: "" }, + { type: 12, id: 1, title: "音频不列" } + ] })(), + "/x/relation/followings": ok({ total: 2, list: [ + { mid: 1, uname: "甲", sign: "写 Rust", tag: [5], special: 0 }, + { mid: 2, uname: "乙", sign: "", tag: null, special: 1 } + ] }), + "/x/relation/tags": ok([{ tagid: 0, name: "默认分组" }, { tagid: 5, name: "编程" }, { tagid: -10, name: "特别关注" }]), + "/x/web-interface/history/cursor": ok({ cursor: { max: 999, view_at: 1700000500, business: "archive" }, list: [ + { title: "看过的", author_name: "UPh", view_at: 1700000600, progress: -1, duration: 300, history: { business: "archive", bvid: "BV1hist00001" } }, + { title: "直播", author_name: "主播", view_at: 1700000550, progress: 0, duration: 0, uri: "https://live.bilibili.com/1", history: { business: "live" } } + ] }) +}); + +const cases = []; +const check = (name, fn) => cases.push([name, fn]); +const reset = () => { + local = {}; + sync = { mcpPersonalData: true }; + routes = baseRoutes(); + fetched.length = 0; + writes.length = 0; +}; + +check("switch off: PERSONAL_DATA_OFF for every method, nothing fetched", async () => { + sync = {}; + for (const name of Object.keys(M)) { + 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 工具读我的收藏、关注和历史/); + } + sync = { mcpPersonalData: false }; + assert.strictEqual((await M.favorites({})).code, "PERSONAL_DATA_OFF"); + assert.strictEqual(fetched.length, 0); +}); + +check("folder list: 稍后再看 first, ids as strings", async () => { + const r = await M.favorites({}); + assert.deepStrictEqual(plain(r), { ok: true, data: { folders: [{ id: "toview", title: "稍后再看", count: 1 }, { id: "11", title: "Rust", count: 2 }, { id: "22", title: "杂", count: 50 }] } }); +}); + +check("a ticked folder reads the 分拣台's copy, with tags, note, verdict, summary and 保留; query searches them", async () => { + local[K.triageIncludedFolders] = [11]; + local[K.triageSnapshot("11")] = { intro: "", items: [ + { bvid: "BV1aaaaaaaaa", title: "所有权", upper: "UPa", duration: 125, favTime: 1700000000, invalid: false }, + { bvid: "BV1bbbbbbbbb", title: "别的", upper: "UPb", duration: 30, favTime: 1700000001, invalid: true } + ] }; + local[K.triageTags] = [{ id: "t1", name: "Rust", folder: "11" }]; + local[K.triageVideoTags] = { BV1aaaaaaaaa: ["t1"] }; + local[K.triageNotes] = { BV1bbbbbbbbb: { text: "讲 rust 宏", updatedAt: 1 } }; + local[K.triageAnalysis("BV1aaaaaaaaa")] = { status: "done", verdict: "keep", oneLiner: "讲透所有权", reason: "好" }; + local[K.triageTitle("BV1bbbbbbbbb")] = { verdict: "unsure", reason: "看不出" }; + local[K.triageKept] = { BV1aaaaaaaaa: { action: "keep", at: 1 } }; + const r = await M.favorites({ folderId: "11" }); + assert.strictEqual(fetched.length, 0, "no B站 request for a folder the 分拣台 keeps"); + assert.deepStrictEqual(plain(r.data), { folderId: "11", source: "local", total: 2, page: 1, hasMore: false, items: [ + { bvid: "BV1aaaaaaaaa", url: "https://www.bilibili.com/video/BV1aaaaaaaaa", title: "所有权", author: "UPa", duration: 125, favTime: 1700000000, tags: ["Rust"], verdict: "keep", summary: "讲透所有权", kept: true }, + // An invalid video is 可清理 whatever the 粗看 said (MdgTagCore.verdictOf). + { bvid: "BV1bbbbbbbbb", url: "https://www.bilibili.com/video/BV1bbbbbbbbb", title: "别的", author: "UPb", duration: 30, favTime: 1700000001, invalid: true, note: "讲 rust 宏", verdict: "drop" } + ] }); + const q = await M.favorites({ folderId: "11", query: "RUST" }); + assert.deepStrictEqual(q.data.items.map((it) => it.bvid), ["BV1aaaaaaaaa", "BV1bbbbbbbbb"], "a tag and a note match"); + assert.deepStrictEqual((await M.favorites({ folderId: "11", query: "rust 宏" })).data.items.map((it) => it.bvid), ["BV1bbbbbbbbb"], "every word"); + assert.deepStrictEqual((await M.favorites({ folderId: "11", page: 2 })).data.items, []); +}); + +check("another folder: one B站 page, B站 searches by keyword (total left out: B站 gives the folder's); only videos", async () => { + // An unticked folder's old copy (until the 分拣台 next opens) is not used. + local[K.triageSnapshot("22")] = { intro: "", items: [{ bvid: "BV1stale0001", title: "旧", upper: "x", duration: 1, favTime: 1 }] }; + local[K.triageTags] = [{ id: "t9", name: "收藏", folder: "22" }]; + local[K.triageVideoTags] = { BV1remote001: ["t9"] }; + const r = await M.favorites({ folderId: 22, page: 1, query: "远程" }); + assert.strictEqual(r.ok, true); + assert.deepStrictEqual(plain(r.data), { folderId: "22", source: "bilibili", page: 1, hasMore: true, items: [ + { bvid: "BV1remote001", url: "https://www.bilibili.com/video/BV1remote001", title: "远程视频", author: "UPr", duration: 61, favTime: 1700000100, tags: ["收藏"] } + ] }); + assert.strictEqual(fetched.length, 1); + const u = new URL(fetched[0].url); + assert.deepStrictEqual([u.searchParams.get("media_id"), u.searchParams.get("pn"), u.searchParams.get("ps"), u.searchParams.get("keyword")], ["22", "1", "40", "远程"]); +}); + +check("稍后再看: the whole list in one request, searched locally, progress kept", async () => { + const r = await M.favorites({ folderId: "toview", query: "rust" }); + assert.deepStrictEqual(plain(r.data), { folderId: "toview", source: "bilibili", total: 1, page: 1, hasMore: false, items: [ + { bvid: "BV1toview001", url: "https://www.bilibili.com/video/BV1toview001", title: "稍后 Rust", author: "UPt", duration: 600, favTime: 1700000000, progress: 120 } + ] }); + assert.strictEqual((await M.favorites({ folderId: "../x" })).ok, false); +}); + +check("follows: the synced list with groups and UP 标签, query on it", async () => { + local[K.follow("list")] = { list: ["1", "2"], special: { 2: 1 }, groups: { 1: [5] } }; + local[K.follow("people")] = { 1: { mid: "1", name: "甲", sign: "写 Rust" }, 2: { mid: "2", name: "乙", sign: "" } }; + local[K.follow("groups")] = [{ id: 5, name: "编程", count: 1 }]; + local[K.follow("tags")] = [{ id: "u1", name: "硬核" }]; + local[K.follow("tag_map")] = { 1: ["u1", "gone"] }; + const r = await M.follows({}); + assert.strictEqual(fetched.length, 0); + assert.deepStrictEqual(plain(r.data), { source: "local", total: 2, page: 1, hasMore: false, items: [ + { mid: "1", name: "甲", sign: "写 Rust", groups: ["编程"], tags: ["硬核"] }, + { mid: "2", name: "乙", sign: "", groups: ["特别关注", "默认分组"] } + ] }); + assert.deepStrictEqual((await M.follows({ query: "硬核" })).data.items.map((u) => u.mid), ["1"]); +}); + +check("follows without a synced list: one B站 page and the group names; query ignored", async () => { + const r = await M.follows({ query: "x" }); + assert.deepStrictEqual(plain(r.data), { source: "bilibili", queryIgnored: true, total: 2, page: 1, hasMore: false, items: [ + { mid: "1", name: "甲", sign: "写 Rust", groups: ["编程"] }, + { mid: "2", name: "乙", sign: "", groups: ["特别关注", "默认分组"] } + ] }); + assert.deepStrictEqual(fetched.map((f) => new URL(f.url).pathname), ["/x/web-interface/nav", "/x/relation/followings", "/x/relation/tags"]); + assert.strictEqual(new URL(fetched[1].url).searchParams.get("ps"), "50"); +}); + +check("history: one page, cursor round trip", async () => { + const r = await M.history({}); + assert.deepStrictEqual(plain(r.data), { cursor: "999:1700000500:archive", items: [ + { bvid: "BV1hist00001", url: "https://www.bilibili.com/video/BV1hist00001", title: "看过的", author: "UPh", viewAt: 1700000600, progress: -1, duration: 300, kind: "archive" }, + { url: "https://live.bilibili.com/1", title: "直播", author: "主播", viewAt: 1700000550, progress: 0, duration: 0, kind: "live" } + ] }); + await M.history({ cursor: r.data.cursor }); + const u = new URL(fetched[1].url); + assert.deepStrictEqual([u.searchParams.get("max"), u.searchParams.get("view_at"), u.searchParams.get("business"), u.searchParams.get("ps")], ["999", "1700000500", "archive", "30"]); + assert.strictEqual((await M.history({ cursor: "1&x=2" })).ok, false); + assert.strictEqual(fetched.length, 2, "a bad cursor asks B站 nothing"); + await M.history({ cursor: "5:1700000400:article-list" }); + assert.strictEqual(new URL(fetched[2].url).searchParams.get("business"), "article-list", "文集's business has a hyphen"); +}); + +check("B站 412 is THROTTLED; not logged in is NOT_LOGGED_IN", async () => { + routes["/x/web-interface/history/cursor"] = () => json("", 412); + assert.strictEqual((await M.history({})).code, "THROTTLED"); + routes["/x/web-interface/nav"] = () => json({ code: -101, data: { isLogin: false } }); + assert.strictEqual((await M.favorites({})).code, "NOT_LOGGED_IN"); +}); + +check("calls run one at a time", async () => { + maxInFlight = 0; + const all = await Promise.all([M.history({}), M.favorites({ folderId: "22" }), M.follows({}), M.favorites({})]); + assert.ok(all.every((r) => r.ok)); + assert.strictEqual(maxInFlight, 1); + assert.ok(gaps.length > 0, "calls wait for the gap"); + assert.strictEqual((await M.history({}, Date.now() - 1)).code, "TIMEOUT", "a call nobody waits for any more asks B站 nothing"); +}); + +(async () => { + for (const [name, fn] of cases) { + reset(); + await fn(); + // Read only: GETs to B站's read endpoints, nothing written to storage. + for (const f of fetched) { + assert.strictEqual(f.method, "GET", `${name}: ${f.url}`); + assert.strictEqual(f.body, undefined); + assert.ok(READS.has(new URL(f.url).pathname), `${name}: ${f.url} is not a read endpoint`); + } + assert.deepStrictEqual(writes, [], `${name}: wrote storage`); + console.log(`ok ${name}`); + } + console.log(`agent-mydata selftest: all ${cases.length} passed`); +})().catch((e) => (console.error(e), process.exit(1))); diff --git a/extension/agent-search.js b/extension/agent-search.js new file mode 100644 index 00000000..a4b539c8 --- /dev/null +++ b/extension/agent-search.js @@ -0,0 +1,122 @@ +// 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. +// 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. +(() => { + const { parseTimestamp, parseCount, localDate } = MdgCommon; + const GAP_MS = 2_000; + const DESCRIPTION_MAX = 200; + + // Bilibili's highlight tags () and entities, whitespace runs as one space. + const plain = (s) => MdgSites.decodeXmlEntities(String(s ?? "").replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim(); + const short = (s) => (s.length > DESCRIPTION_MAX ? `${s.slice(0, DESCRIPTION_MAX)}…` : s); + const seconds = (s) => Math.max(0, parseTimestamp(s)); + + // data of x/web-interface/wbi/search/type?search_type=video → items. + function biliItems(data) { + return (data?.result || []) + .filter((r) => r?.type === "video" && r.bvid) + .map((r) => ({ + site: "bilibili", + videoId: r.bvid, + url: `https://www.bilibili.com/video/${r.bvid}`, + title: plain(r.title), + author: plain(r.author), + duration: seconds(r.duration), + views: Number.isFinite(r.play) ? r.play : null, + uploadDate: r.pubdate > 0 ? localDate(r.pubdate * 1000) : "", + description: short(plain(r.description)) + })); + } + + const ytText = (t) => t?.simpleText ?? (t?.runs || []).map((r) => r.text).join(""); + + // A youtube.com/results page's HTML → items, or null when it carries no ytInitialData (a consent or error page). + // Only the result list's own videoRenderer items: shorts shelves, ads, channels, playlists and "people also watched" + // shelves use other renderers or sit inside shelves. + function ytItems(html) { + const m = /ytInitialData"?\]?\s*=\s*(\{.+?\});\s*<\/script>/s.exec(html); + if (!m) return null; + let data; + try { data = JSON.parse(m[1]); } catch { return null; } + const sections = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || []; + return sections + .flatMap((s) => s.itemSectionRenderer?.contents || []) + .map((c) => c.videoRenderer) + .filter((v) => v?.videoId) + .map((v) => { + const views = ytText(v.viewCountText); + return { + site: "youtube", + videoId: v.videoId, + url: `https://www.youtube.com/watch?v=${v.videoId}`, + title: plain(ytText(v.title)), + author: plain(ytText(v.ownerText || v.longBylineText)), + duration: seconds(ytText(v.lengthText)), // a live stream has none: 0 + views: /\d/.test(views) ? parseCount(views) : null, + uploadDate: ytText(v.publishedTimeText), + description: short(plain(ytText(v.detailedMetadataSnippets?.[0]?.snippetText) || ytText(v.descriptionSnippet))) + }; + }); + } + + const fail = (error, code) => Object.assign(new Error(error), code ? { code } : {}); + + async function searchBili(query, page) { + const params = { search_type: "video", keyword: query, page }; + try { + // A function: the channel signs it again if it retries. + const data = await triageBiliGet(async () => `${TRIAGE_BILI_API}/x/web-interface/wbi/search/type?${await MdgSites.biliWbiSigned(params, TRIAGE_BILI_IO)}`); + return biliItems(data); + } catch (e) { + // A stale WBI key answers -352 like risk control: the next search fetches a fresh one. + if (e.code === MdgErrors.CODE.THROTTLED) MdgSites.biliWbiReset(); + throw e; + } + } + + async function searchYt(query, page) { + // ponytail: the results page is YouTube's first page only; later pages need the youtubei continuation API. + if (page > 1) throw fail("YouTube search returns its first page only (about 20 videos): use page 1."); + const res = await fetchWithTimeout(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`, { credentials: "include" }).catch((e) => { + throw fail(e?.status ? e.message : MdgErrors.TEXT.netDown); + }); + if (res.status === 429) throw fail(MdgErrors.TEXT.ytThrottled, MdgErrors.CODE.THROTTLED); + if (!res.ok) throw fail(`YouTube answered HTTP ${res.status}.`); + const items = ytItems(await res.text()); + if (!items) throw fail("YouTube did not return a search results page (a consent or sign-in page?). Open youtube.com in that browser once."); + return items; + } + + // ponytail: one global queue with a fixed gap; per-site queues if Bilibili and YouTube searches should overlap. + let queue = Promise.resolve(); + let lastAt = 0; + + // params { site, query, page? } → { ok, data: { site, query, page, items } } or { ok: false, error, code? }. + // deadline: as agent-bridge.js handle; a search still queued then is dropped with TIMEOUT. + function search(params, deadline) { + 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".' }); + 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 () => { + const wait = lastAt + GAP_MS - Date.now(); + 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); + return { ok: true, data: { site, query, page, items } }; + } catch (e) { + return { ok: false, error: e.message, ...(e.code === MdgErrors.CODE.THROTTLED && { code: e.code }) }; + } finally { + lastAt = Date.now(); + } + }); + queue = run.catch(() => {}); + return run; + } + + globalThis.MdgAgentSearch = { search, biliItems, ytItems }; +})(); diff --git a/extension/agent-search.selftest.js b/extension/agent-search.selftest.js new file mode 100644 index 00000000..24fce2df --- /dev/null +++ b/extension/agent-search.selftest.js @@ -0,0 +1,101 @@ +// node extension/agent-search.selftest.js +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const waits = []; +const calls = []; +let wbiResets = 0; +const ctx = vm.createContext({ + URL, URLSearchParams, TextEncoder, console, + // The gap between searches: recorded, not waited. + 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); +const A = ctx.MdgAgentSearch; +const eq = (actual, expected) => assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected); + +// Bilibili: highlight tags and entities stripped, "12:34" → 754, non-video results dropped. +eq(A.biliItems({ + result: [ + { type: "video", bvid: "BV1xx411c7mD", title: 'Rust & 所有权 "入门"', author: "UP主", duration: "12:34", play: 12345, pubdate: 1700000000, description: "第一行\n第二行" }, + { type: "video", bvid: "BV1yy411c7mE", title: "长视频", author: "a", duration: "75:10", play: 0, pubdate: 0, description: "x".repeat(300) }, + { type: "ketang", title: "课程" } + ] +}).map(({ uploadDate, description, ...r }) => ({ ...r, uploadDate: /^\d{4}-\d{2}-\d{2}$/.test(uploadDate) || uploadDate, description: description.length })), [ + { site: "bilibili", videoId: "BV1xx411c7mD", url: "https://www.bilibili.com/video/BV1xx411c7mD", title: 'Rust & 所有权 "入门"', author: "UP主", duration: 754, views: 12345, uploadDate: true, description: 7 }, + { site: "bilibili", videoId: "BV1yy411c7mE", url: "https://www.bilibili.com/video/BV1yy411c7mE", title: "长视频", author: "a", duration: 4510, views: 0, uploadDate: "", description: 201 } +]); +eq(A.biliItems({}), []); +eq(A.biliItems(null), []); + +// YouTube: only the result list's own videoRenderer items; shorts, ads, channels and shelves are skipped. +const video = (id, extra = {}) => ({ videoRenderer: { videoId: id, title: { runs: [{ text: `Title ${id}` }] }, ownerText: { runs: [{ text: "Chan" }] }, lengthText: { simpleText: "1:02:03" }, viewCountText: { simpleText: "1,234,567 views" }, publishedTimeText: { simpleText: "2 years ago" }, detailedMetadataSnippets: [{ snippetText: { runs: [{ text: "About " }, { text: "rust" }] } }], ...extra } }); +const initial = { + contents: { twoColumnSearchResultsRenderer: { primaryContents: { sectionListRenderer: { contents: [ + { itemSectionRenderer: { contents: [ + { adSlotRenderer: {} }, + video("aaa"), + { channelRenderer: { channelId: "UC1" } }, + { reelShelfRenderer: { items: [{ reelItemRenderer: { videoId: "short1" } }] } }, + { shelfRenderer: { content: { verticalListRenderer: { items: [video("shelf1")] } } } }, + { playlistRenderer: { playlistId: "PL1" } }, + video("live", { lengthText: undefined, viewCountText: { runs: [{ text: "No views" }] }, publishedTimeText: undefined, detailedMetadataSnippets: undefined, descriptionSnippet: { runs: [{ text: "live & now" }] } }) + ] } }, + { continuationItemRenderer: {} } + ] } } } } +}; +const html = ``; +eq(A.ytItems(html), [ + { site: "youtube", videoId: "aaa", url: "https://www.youtube.com/watch?v=aaa", title: "Title aaa", author: "Chan", duration: 3723, views: 1234567, uploadDate: "2 years ago", description: "About rust" }, + { site: "youtube", videoId: "live", url: "https://www.youtube.com/watch?v=live", title: "Title live", author: "Chan", duration: 0, views: null, uploadDate: "", description: "live & now" } +]); +assert.strictEqual(A.ytItems("consent"), null); +eq(A.ytItems(``), []); + +// search(): validation, one at a time with the gap, THROTTLED kept as the code. +ctx.MdgSites.biliWbiReset = () => wbiResets++; +ctx.MdgSites.biliWbiSigned = async (p) => new URLSearchParams(p).toString(); +vm.runInContext("var TRIAGE_BILI_API = 'https://api.bilibili.com', TRIAGE_BILI_IO = {};", ctx); +let biliAnswer = () => ({ result: [{ type: "video", bvid: "BV1", title: "t", author: "a", duration: "1:00", play: 1, pubdate: 0, description: "" }] }); +ctx.triageBiliGet = async (url) => (calls.push(await url()), biliAnswer()); +let ytStatus = 200; +ctx.fetchWithTimeout = async (url, init) => (calls.push(url), { ok: ytStatus < 300, status: ytStatus, text: async () => html, init }); + +(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: "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); + + const [b, y] = await Promise.all([A.search({ site: "bilibili", query: " rust ", page: 2 }, far), A.search({ site: "youtube", query: "rust 所有权" }, far)]); + assert.strictEqual(b.ok, true); + eq({ ...b.data, items: b.data.items.length }, { site: "bilibili", query: "rust", page: 2, items: 1 }); + assert.strictEqual(calls[0], "https://api.bilibili.com/x/web-interface/wbi/search/type?search_type=video&keyword=rust&page=2"); + assert.strictEqual(y.data.items.length, 2); + assert.strictEqual(calls[1], "https://www.youtube.com/results?search_query=rust%20%E6%89%80%E6%9C%89%E6%9D%83"); + assert.ok(waits.some((ms) => ms > 1500 && ms <= 2000), `the second search waited the gap: ${waits}`); + + // Empty is no error. + biliAnswer = () => ({}); + eq(await A.search({ site: "bilibili", query: "nothing" }, far), { ok: true, data: { site: "bilibili", query: "nothing", page: 1, items: [] } }); + + // Risk control: THROTTLED code, WBI key dropped. + biliAnswer = () => { throw Object.assign(new Error("B站限流"), { code: "THROTTLED" }); }; + eq(await A.search({ site: "bilibili", query: "x" }, far), { ok: false, error: "B站限流", code: "THROTTLED" }); + assert.strictEqual(wbiResets, 1); + ytStatus = 429; + const t = await A.search({ site: "youtube", query: "x" }, far); + assert.strictEqual(t.code, "THROTTLED"); + ytStatus = 500; + eq(await A.search({ site: "youtube", query: "x" }, far), { ok: false, error: "YouTube answered HTTP 500." }); + const n = calls.length; + assert.match((await A.search({ site: "youtube", query: "x", page: 2 }, far)).error, /first page only/); + // Queued past the deadline: dropped before any request. + eq(await A.search({ site: "youtube", query: "x" }, Date.now() - 1), { ok: false, code: "TIMEOUT", error: "The search waited too long behind earlier ones." }); + assert.strictEqual(calls.length, n); + console.log("agent-search selftest ok"); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/extension/background.js b/extension/background.js index ac821c07..96810afb 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,6 +1,6 @@ // 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-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-search.js", "agent-bridge.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 @@ -1295,6 +1295,8 @@ async function probeAiChatCompletion({ baseUrl, apiKey, model }) { // background is the only writer to Obsidian. // follow-core.js (FollowCore) gives follow-bg.js the page's groupsOf, so an UP's groups are read one way on both sides. importScripts("obsidian.js", "triage/triage-bg.js", "triage/follow-core.js", "triage/follow-bg.js"); +// The agent interface's personal-data methods (docs/AGENT.md §2) read through triage-bg.js and follow-core.js. +importScripts("agent-mydata.js"); // 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/entry-matrix.selftest.js b/extension/entry-matrix.selftest.js index 34ceb87d..6ecf509e 100644 --- a/extension/entry-matrix.selftest.js +++ b/extension/entry-matrix.selftest.js @@ -131,6 +131,7 @@ const NOT_ENTRIES = { "background.js": "classifies (streamChat AI_STALLED, aiProviderProblem) and answers open-options; shows no error", "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", "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/options.css b/extension/options.css index 6fc7927b..15d59fa4 100644 --- a/extension/options.css +++ b/extension/options.css @@ -391,6 +391,23 @@ main { justify-self: end; } +/* A switch that works only while the one above it is on (允许 AI 工具读… under 允许 MCP 连接): indented under it, no rule + between them, greyed out while that one is off. */ +.row.sub { + padding-top: 0; + padding-left: 24px; + border-top: 0; +} + +.row.sub:has(.switch:disabled) .name { + color: var(--muted); +} + +input.switch:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* 笔记目录 carries its variable buttons; give them room so they wrap less. */ .row:has(.vars) { grid-template-columns: minmax(0, 1fr) 420px; diff --git a/extension/options.html b/extension/options.html index a5998e35..a57ed123 100644 --- a/extension/options.html +++ b/extension/options.html @@ -347,9 +347,13 @@

高级

-

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

+

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

+
+

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

+
+
导出设置

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

diff --git a/extension/options.js b/extension/options.js index 281a5b4a..598c7023 100644 --- a/extension/options.js +++ b/extension/options.js @@ -132,6 +132,7 @@ function init() { scheduleSave(); }); elements.obsidianEnabled.addEventListener("change", syncObsidianBody); + elements.mcpEnabled.addEventListener("change", syncMcpRows); document.addEventListener("click", (event) => { if (!(event.target instanceof Element) || !event.target.closest(".fixed-property-type-picker")) { closeAllFixedPropertyMenus(); @@ -280,6 +281,7 @@ async function loadSettings() { for (const k of SWITCH_KEYS) elements[k].checked = settings[k]; for (const k of VALUE_KEYS) elements[k].value = String(settings[k] ?? ""); syncObsidianBody(); + syncMcpRows(); renderBiliMarks(settings.biliMarksOff); syncSeenRows(); syncBiliMarks(); @@ -687,6 +689,11 @@ function validateNoteExtras() { } // Note-format settings stay visible (copy and download use them); only Obsidian-specific text hides. +// 允许 AI 工具读我的收藏… works only over the MCP connection: greyed out while 允许 MCP 连接 is off, keeping its own value. +function syncMcpRows() { + MdgErrors.setReason(elements.mcpPersonalData, elements.mcpEnabled.checked ? "" : "先打开「允许 MCP 连接」"); +} + function syncObsidianBody() { document.querySelectorAll("#obsidianBody, .obsidian-only").forEach((el) => (el.hidden = !elements.obsidianEnabled.checked)); } diff --git a/extension/options.selftest.js b/extension/options.selftest.js index d88de806..8be3859c 100644 --- a/extension/options.selftest.js +++ b/extension/options.selftest.js @@ -497,7 +497,7 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh // the page has (a switch flipped from its default, every box changed). (async () => { const html = fs.readFileSync(path.join(__dirname, "options.html"), "utf8"); - const els = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, { id, value: "", checked: false, querySelectorAll: () => [] }])); + const els = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, { id, value: "", checked: false, querySelectorAll: () => [], setAttribute() {}, removeAttribute() {} }])); const S = ctx.MdgSettings; const stored = S.normalizeSyncSettings({ ...Object.fromEntries(Object.entries(S.DEFAULTS).map(([k, v]) => [k, typeof v === "boolean" ? !v : v])), @@ -517,12 +517,25 @@ assert.ok(!/seenThreshold\.value = String\((readFormPayload\(\)|payload)\.seenTh renderBiliMarks() {}, renderStorageLimits() {}, syncObsidianBody() {}, syncSeenRows() {}, syncBiliMarks() {}, renderAiProviders() {}, renderHostPermissionBanner() {}, hostPermissionUrls() {}, markSaved() {} }); - vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload")].join("\n"), page); + vm.runInContext([constant("DEFAULT_SETTINGS"), constant("elements"), "elements.frontmatterFields = document.querySelectorAll(\"input[name=frontmatterField]\");", constant("formKeys"), constant("SWITCH_KEYS"), constant("VALUE_KEYS"), `async ${fn("loadSettings")}`, fn("readFormPayload"), fn("syncMcpRows")].join("\n"), page); await page.loadSettings(); const payload = page.readFormPayload(); - const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; + const keys = ["obsidianEnabled", "noteFolder", "obsidianApiBaseUrl", "obsidianApiKey", "tags", "downloadFormat", "youtubeSubtitleLang", "includeDateInFilename", "includeHotCommentsInNote", "includeCoverInNote", "includeAiChatInNote", "enablePlayerAiQuickAction", "playerAiQuickPrompt", "includeTimestampInBody", "showBiliTriageBadges", "showBiliUpTags", "showBiliVerdicts", "biliMarksOff", "seenShow", "seenThreshold", "seenStyle", "enableDebugLogs", "mcpEnabled", "mcpPersonalData", "frontmatterFields", "fixedFrontmatterProperties", "notePlaceholderSections", "aiSystemPrompt", "aiPresetPrompts", "webAiSystemPrompt", "webAiSummaryPrompt", "webAiPresetPrompts"]; assert.deepStrictEqual(Object.keys(payload).sort(), [...keys].sort(), "the page sends exactly its own settings"); assert.deepStrictEqual(plainOf(payload), plainOf(Object.fromEntries(keys.map((k) => [k, settings[k]])))); + // 允许 AI 工具读我的收藏… hangs on 允许 MCP 连接: greyed out while it is off, its own value kept either way. + assert.deepStrictEqual([els.mcpEnabled.checked, els.mcpPersonalData.checked, els.mcpPersonalData.disabled], [true, true, false]); + els.mcpEnabled.checked = false; + page.syncMcpRows(); + assert.deepStrictEqual([els.mcpPersonalData.disabled, els.mcpPersonalData.title], [true, "先打开「允许 MCP 连接」"]); + assert.deepStrictEqual([page.readFormPayload().mcpEnabled, page.readFormPayload().mcpPersonalData], [false, true], "turning MCP off keeps the stored value"); + els.mcpEnabled.checked = true; + page.syncMcpRows(); + assert.deepStrictEqual([els.mcpPersonalData.disabled, page.readFormPayload().mcpPersonalData], [false, true], "and turning it on again brings it back as it was"); + els.mcpPersonalData.checked = false; + els.mcpEnabled.checked = false; + page.syncMcpRows(); + assert.deepStrictEqual([els.mcpPersonalData.disabled, page.readFormPayload().mcpPersonalData], [true, false], "an off one stays off"); })().catch((e) => (console.error(e), process.exit(1))); function plainOf(v) { return JSON.parse(JSON.stringify(v)); diff --git a/extension/settings.js b/extension/settings.js index 6a55ae4d..832bf854 100644 --- a/extension/settings.js +++ b/extension/settings.js @@ -82,6 +82,8 @@ enableDebugLogs: false, // The agent interface's MCP socket (agent-bridge.js in the background, docs/AGENT.md): off until 设置 → 高级 → 允许 MCP 连接. mcpEnabled: false, + // The MCP server may also read the user's B站 favorites, follows and history (agent-mydata.js): off until 允许 AI 工具读我的收藏、关注和历史. + mcpPersonalData: false, readerTheme: "light", readerFontScale: "m", readerLetterSpacing: "normal", diff --git a/extension/triage/triage-bg.js b/extension/triage/triage-bg.js index a5168a5d..9f02da7f 100644 --- a/extension/triage/triage-bg.js +++ b/extension/triage/triage-bg.js @@ -957,6 +957,53 @@ function triageBvToAv(bvid) { return String((n ^ 23442827791579n) & ((1n << 51n) - 1n)); } +// One 稍后再看 entry as the 分拣台's item (agent-mydata.js reads 稍后再看 the same way). +function triageToviewItem(m) { + return { + bvid: m.bvid, + aid: m.aid, + title: m.title, + cover: m.pic, + upper: m.owner?.name || "", + upperMid: m.owner?.mid ? String(m.owner.mid) : undefined, + duration: m.duration, + pubdate: m.pubdate, + favTime: m.add_at, + intro: m.desc, + invalid: m.state !== 0, + // Seconds watched on Bilibili, -1 once finished; the card shows it as 看到 N% or 已看完. + seen: m.progress, + // 播放量 comes with the list; no extra request. + play: Number.isFinite(m.stat?.view) ? m.stat.view : undefined, + // A 番剧 episode redirects to its /bangumi/ page and has a pgc_label (season_id can be 0 on a video): marked so a + // write into a folder sends aid:42; B站 would store aid:2 as an odd video entry. + ...((/\/bangumi\//.test(m.redirect_url || "") || m.pgc_label) && { ogv: true }) + }; +} + +// One favorites list entry as the 分拣台's item (agent-mydata.js too); null for types the 分拣台 does not list. +function triageFavItem(m) { + const ogv = TRIAGE_OGV.includes(m.type); + if (m.type !== 2 && !ogv) return null; + return { + bvid: m.bvid || m.bv_id, + aid: ogv ? triageBvToAv(m.bvid || m.bv_id) : m.id, + ...(ogv && { ogv: true, ep: `${m.id}:${m.type}` }), + // A 番剧 episode: title is the season's, intro the episode's (第1集 …); its counts are all 0. + title: ogv && m.intro ? `${m.title} ${m.intro}` : m.title, + cover: m.cover, + upper: m.upper?.name || "", + // The UP's mid finds its 关注 UP 标签 in the player; snapshots saved before have none (matched by name). + upperMid: m.upper?.mid ? String(m.upper.mid) : undefined, + duration: m.duration, + pubdate: m.pubtime, + favTime: m.fav_time, + intro: ogv ? "" : m.intro, + invalid: m.attr !== 0, + play: !ogv && Number.isFinite(m.cnt_info?.play) ? m.cnt_info.play : undefined + }; +} + const TRIAGE_HANDLERS = { "triage-open": (msg) => triageOpenPage(msg?.hash), "triage-write-note": (msg) => triageWriteNote(msg), @@ -973,26 +1020,7 @@ const TRIAGE_HANDLERS = { "triage-folder-items": async ({ mediaId, known }) => { if (!triageFolderId(mediaId)) throw triageError("缺少 mediaId"); if (mediaId === TRIAGE_TOVIEW) { - const items = (await triageToviewList()).map((m) => ({ - bvid: m.bvid, - aid: m.aid, - title: m.title, - cover: m.pic, - upper: m.owner?.name || "", - upperMid: m.owner?.mid ? String(m.owner.mid) : undefined, - duration: m.duration, - pubdate: m.pubdate, - favTime: m.add_at, - intro: m.desc, - invalid: m.state !== 0, - // Seconds watched on Bilibili, -1 once finished; the card shows it as 看到 N% or 已看完. - seen: m.progress, - // 播放量 comes with the list; no extra request. - play: Number.isFinite(m.stat?.view) ? m.stat.view : undefined, - // A 番剧 episode redirects to its /bangumi/ page and has a pgc_label (season_id can be 0 on a video): marked so a - // write into a folder sends aid:42; B站 would store aid:2 as an odd video entry. - ...((/\/bangumi\//.test(m.redirect_url || "") || m.pgc_label) && { ogv: true }) - })); + const items = (await triageToviewList()).map(triageToviewItem); return { items, ids: items.map((it) => it.bvid), info: { title: MdgErrors.LABEL.watchLater, intro: "" } }; } const items = []; @@ -1011,25 +1039,8 @@ const TRIAGE_HANDLERS = { } info ||= data?.info ? { title: data.info.title || "", intro: data.info.intro || "" } : null; for (const m of data?.medias || []) { - const ogv = TRIAGE_OGV.includes(m.type); - if (m.type !== 2 && !ogv) continue; - items.push({ - bvid: m.bvid || m.bv_id, - aid: ogv ? triageBvToAv(m.bvid || m.bv_id) : m.id, - ...(ogv && { ogv: true, ep: `${m.id}:${m.type}` }), - // A 番剧 episode: title is the season's, intro the episode's (第1集 …); its counts are all 0. - title: ogv && m.intro ? `${m.title} ${m.intro}` : m.title, - cover: m.cover, - upper: m.upper?.name || "", - // The UP's mid finds its 关注 UP 标签 in the player; snapshots saved before have none (matched by name). - upperMid: m.upper?.mid ? String(m.upper.mid) : undefined, - duration: m.duration, - pubdate: m.pubtime, - favTime: m.fav_time, - intro: ogv ? "" : m.intro, - invalid: m.attr !== 0, - play: !ogv && Number.isFinite(m.cnt_info?.play) ? m.cnt_info.play : undefined - }); + const item = triageFavItem(m); + if (item) items.push(item); } if (!data?.has_more) break; // More to come but nothing on this page (seen under risk control): what follows is unread, not gone. diff --git a/mcp/README.md b/mcp/README.md index e49eff56..3e7ba27e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -6,17 +6,26 @@ ### 这是什么 -一个可选的 MCP 服务器。装了它,Claude Code、Cursor、Codex 这类 MCP 客户端就能借你自己的浏览器读 B站和 YouTube 视频:字幕、章节、热评,以及任意时刻的一帧画面。真正读视频的是浏览器里的 MoonDigest 扩展,这个服务器只负责转发。 +一个可选的 MCP 服务器。装了它,Claude Code、Cursor、Codex 这类 MCP 客户端就能借你自己的浏览器搜索和读 B站、YouTube 视频:搜索结果、字幕、章节、热评,以及任意时刻的一帧画面。真正读视频的是浏览器里的 MoonDigest 扩展,这个服务器只负责转发。 ``` MCP 客户端 ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823──> 扩展的 service worker ──> 视频标签页 ``` -两个工具: +搜索和读视频的三个工具: +- `search_videos({ query, site?, page?, raw? })`:在 B站(默认)或 YouTube 搜视频,每个视频一行:标题、作者、时长、播放量、日期、链接,链接可以直接交给 `get_video`。`page` 从 1 开始,每页约 20 个;YouTube 只有第 1 页。`raw: true` 返回 JSON,多一段简介。 - `get_video({ url, raw? })`:标题、作者、日期、时长、章节、带时间戳的全文字幕、热评。 - `get_frame({ url, at, maxWidth? })`:`at` 秒处的一帧 JPEG,外加一行实际时间。 +读你自己 B站 数据的三个工具,都只读,默认关闭,要在「允许 MCP 连接」下面再打开「允许 AI 工具读我的收藏、关注和历史」,关着时返回 `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` 不生效,结果里会写明。 +- `get_history({ cursor?, raw? })`:观看历史,从新到旧,每次 30 条,把返回的 `cursor` 传回去读更早的。 + +这三个工具一次只读一页,同一时间只跑一个,不会一口气读完。 + `get_video` 默认返回紧凑文本,每条字幕一行 `[mm:ss] 内容`(超过一小时用 `hh:mm:ss`)。扩展原始 JSON 每条字幕都带 `from`、`to`、`content` 三个键名和小数时间,实测体积约是文本的两倍,而模型几乎用不到结束时间。确实要精确时间时传 `raw: true`。 ### 用 MCP 还是用 skill @@ -71,7 +80,9 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - 只接受 `Origin` 以 `chrome-extension://` 开头的连接。网页也能向 localhost 开 WebSocket,这一条把它们挡在外面(握手直接被拒)。但任何已装的扩展都能通过这一条:你装的别的扩展可以抢先连上,给 agent 回假数据。服务器信任你自己装的扩展。 - 同一时间只认一个扩展。第二个连上来会被关闭(关闭码 4001)。 - 扩展只会打开 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 客户端同时开着 @@ -93,6 +104,8 @@ args = ["/path/to/MoonDigest/mcp/server.js"] - `TIMEOUT`:60 秒内没有回答。常见原因是页面加载太慢,或后台标签页被浏览器冻结。切到那个标签页看一眼再试。 - 字幕说要登录(`Subtitle note`):在这个浏览器里登录 B站。 - `AD_PLAYING`:YouTube 广告一直在播,等广告结束再取帧。 +- `PERSONAL_DATA_OFF`:「允许 AI 工具读我的收藏、关注和历史」没开。 +- `NOT_LOGGED_IN`:这个浏览器没登录 B站。 ### 测试 @@ -104,17 +117,26 @@ cd mcp && npm test ### What it is -An optional MCP server. It lets MCP clients such as Claude Code, Cursor and Codex read Bilibili and YouTube videos through your own browser: transcript, chapters, hot comments, and a frame at any time. The MoonDigest extension in your browser does the reading. This server only relays. +An optional MCP server. It lets MCP clients such as Claude Code, Cursor and Codex search and read Bilibili and YouTube videos through your own browser: search results, transcript, chapters, hot comments, and a frame at any time. The MoonDigest extension in your browser does the reading. This server only relays. ``` MCP client ──stdio──> moondigest-mcp ──ws://127.0.0.1:47823──> extension service worker ──> video tab ``` -Two tools: +Three tools search and read videos: +- `search_videos({ query, site?, page?, raw? })`: search Bilibili (default) or YouTube, one line per video: title, author, duration, views, date, URL. The URLs go straight to `get_video`. `page` starts at 1, about 20 videos a page; YouTube has page 1 only. `raw: true` returns JSON with a short description per video. - `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`. + +- `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. +- `get_history({ cursor?, raw? })`: watch history, newest first, 30 a call. Pass the returned `cursor` back for older items. + +These return one page per call and run one at a time, a second apart. + `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 @@ -169,7 +191,9 @@ Then turn on 允许 MCP 连接 in MoonDigest's settings (高级). - 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. +- `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. ### Two MCP clients at once @@ -191,6 +215,8 @@ 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. ### Tests diff --git a/mcp/server.js b/mcp/server.js index 8fc44df3..5f8371c2 100755 --- a/mcp/server.js +++ b/mcp/server.js @@ -162,11 +162,76 @@ export function formatVideo(d) { return lines.join("\n"); } +// 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"; + 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) => { + const parts = [v.title, v.author, v.duration ? clock(v.duration) : "", v.views != null ? `${v.views} views` : "", v.uploadDate, v.url]; + lines.push(`${i + 1}. ${parts.filter(Boolean).join(" | ")}`); + }); + return lines.join("\n"); +} + +// The user's own Bilibili data (docs/AGENT.md §2 favorites / follows / history): one line per item. +// Local time, as "2026-11-05 21:30" (sv-SE writes dates that way). +const when = (sec) => (sec > 0 ? new Date(sec * 1000).toLocaleString("sv-SE").slice(0, 16) : ""); +const day = (sec) => when(sec).slice(0, 10); +const joinParts = (parts) => parts.filter(Boolean).join(" · "); +const pageLine = (d) => `Page ${d.page}, ${d.items.length}${d.total == null ? "" : ` of ${d.total}`}${d.source === "local" ? " (MoonDigest's copy)" : ""}.${d.hasMore ? ` More: page ${d.page + 1}.` : ""}`; + +export function formatFavorites(d) { + if (d.folders) return ["Folders (id · title · videos):", ...d.folders.map((f) => `- ${f.id} · ${f.title} · ${f.count}`)].join("\n"); + const lines = [`Folder ${d.folderId}. ${pageLine(d)}`]; + for (const v of d.items) { + lines.push("- " + joinParts([ + `[${clock(v.duration)}] ${v.title}`, v.author, v.url, v.favTime && `saved ${day(v.favTime)}`, + v.invalid && "unavailable", v.progress && (v.progress < 0 ? "watched" : `watched to ${clock(v.progress)}`), + v.kept && "kept", v.verdict && `AI: ${v.verdict}`, v.tags?.length && `tags: ${v.tags.join(", ")}`, + v.summary && `summary: ${v.summary}`, v.note && `note: ${v.note.replace(/\s+/g, " ")}`, + ])); + } + return lines.join("\n"); +} + +export function formatFollows(d) { + const ignored = d.queryIgnored ? " Query ignored: MoonDigest has not synced the follow list, so this is Bilibili's list unfiltered." : ""; + const lines = [`Follows. ${pageLine(d)}${ignored}`]; + for (const u of d.items) { + lines.push("- " + joinParts([`${u.name} (mid ${u.mid})`, u.groups?.length && `groups: ${u.groups.join(", ")}`, u.tags?.length && `tags: ${u.tags.join(", ")}`, u.sign?.replace(/\s+/g, " ")])); + } + return lines.join("\n"); +} + +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) { + const seen = h.progress < 0 ? "finished" : h.duration ? `${clock(h.progress)}/${clock(h.duration)}` : ""; + lines.push("- " + joinParts([when(h.viewAt), h.title, h.author, seen && `watched ${seen}`, h.kind !== "archive" && h.kind, h.url])); + } + 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=…)."; const TOOLS = [ + { + name: "search_videos", + description: "Search Bilibili or YouTube for videos through the user's own browser and login. Returns one line per video (title, author, duration, views, date, URL); the URLs go straight to get_video. Titles and view counts are not evidence: read the most promising few with get_video before you cite or recommend one. One search at a time, a few per task. If the result says THROTTLED, stop calling MoonDigest and tell the user; do not retry soon.", + inputSchema: { + 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." }, + 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." }, + }, + required: ["query"], + }, + }, { name: "get_video", description: "Read a Bilibili or YouTube video through the user's own browser: title, author, date, duration, chapters, the full timestamped transcript and hot comments. Call this first for any video: one the user links, and one that turns up in your search results or recommendations, to check what it actually says before you cite or recommend it. Work on one video at a time. If the result says THROTTLED, stop calling MoonDigest and tell the user; do not retry soon.", @@ -192,13 +257,57 @@ const TOOLS = [ required: ["url", "at"], }, }, + { + name: "get_favorites", + description: "Read-only: the user's own Bilibili favorites. Without folderId, lists the folders (id, title, video count), 稍后再看 (watch later) first with id \"toview\". With folderId, one page of that folder's videos: title, UP, URL, when saved, plus the user's MoonDigest 分拣 tags, note, AI verdict (keep/drop/unsure) and one-line summary where they exist. query searches title, UP, tags, note and summary in toview and in folders MoonDigest keeps a copy of; in other folders Bilibili searches its own titles. Read one page, then decide whether you need the next; do not page through everything. Fails with PERSONAL_DATA_OFF until the user allows it.", + inputSchema: { + type: "object", + properties: { + folderId: { type: "string", description: "A folder id from the folder list, or \"toview\". Omit to list the folders." }, + page: { type: "integer", minimum: 1, description: "Page number, 40 videos a page. Default 1." }, + query: { type: "string", description: "Words that must all appear (case-insensitive)." }, + raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, + }, + }, + }, + { + name: "get_follows", + description: "Read-only: the UP 主 the user follows on Bilibili, with their follow groups and the user's MoonDigest UP tags, 50 a page. query searches name, sign, groups and tags once MoonDigest has synced the follow list (otherwise it is ignored and the result says so). Page sparingly. Fails with PERSONAL_DATA_OFF until the user allows it.", + inputSchema: { + type: "object", + properties: { + page: { type: "integer", minimum: 1, description: "Page number, 50 a page. Default 1." }, + query: { type: "string", description: "Words that must all appear (case-insensitive)." }, + raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, + }, + }, + }, + { + name: "get_history", + description: "Read-only: the user's Bilibili watch history, newest first, 30 items a call (Bilibili keeps about three months). Pass the returned cursor to read further back. For \"what did I watch this month\", stop once items are older than the month; never read the whole history. Fails with PERSONAL_DATA_OFF until the user allows it.", + inputSchema: { + type: "object", + properties: { + cursor: { type: "string", description: "The cursor the previous call returned. Omit for the newest." }, + raw: { type: "boolean", description: "Return the extension's JSON instead of the compact text." }, + }, + }, + }, ]; +// The personal-data tools: socket method, the params it takes, the formatter. +const PERSONAL = { + get_favorites: ["favorites", ["folderId", "page", "query"], formatFavorites], + get_follows: ["follows", ["page", "query"], formatFollows], + get_history: ["history", ["cursor"], formatHistory], +}; + // What MCP clients put in the model's prompt about this server: when to reach for it, not how each tool works. export const INSTRUCTIONS = [ - "MoonDigest reads Bilibili and YouTube videos through the user's own browser and login: the full timestamped transcript, chapters, description and hot comments (get_video), and a frame at any second (get_frame).", + "MoonDigest searches and reads Bilibili and YouTube videos through the user's own browser and login: a video search on either site (search_videos), the full timestamped transcript, chapters, description and hot comments (get_video), and a frame at any second (get_frame).", "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. MoonDigest cannot search; find the videos with your own search tools first.", + "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.", ].join("\n\n"); function errorResult(err) { @@ -213,6 +322,21 @@ export function createMcpServer(bridge) { server.setRequestHandler(CallToolRequestSchema, async ({ params }) => { const args = params.arguments || {}; try { + 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".'); + 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]; + 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 (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 8f42dc31..ccff8192 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, formatVideo } from "../server.js"; +import { createBridge, createMcpServer, formatSearch, formatVideo, formatFavorites, formatFollows, formatHistory } from "../server.js"; const SERVER = fileURLToPath(new URL("../server.js", import.meta.url)); @@ -173,15 +173,17 @@ test("port taken -> PORT_IN_USE, then takes the port over when it frees", async await s.close(); }); -test("stdio binary lists the two tools", async () => { +test("stdio binary lists the tools", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: [SERVER], env: { ...process.env, MOONDIGEST_MCP_PORT: "0" } }); const client = new Client({ name: "test", version: "0" }); await client.connect(transport); const { tools } = await client.listTools(); - assert.deepEqual(tools.map((t) => t.name), ["get_video", "get_frame"]); + assert.deepEqual(tools.map((t) => t.name), ["search_videos", "get_video", "get_frame", "get_favorites", "get_follows", "get_history"]); // 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[0].description, /search results/); + assert.match(tools.find((t) => t.name === "get_video").description, /search results/); + assert.match(client.getInstructions() || "", /search with search_videos, then read the best few with get_video/); + assert.doesNotMatch(client.getInstructions() || "", /cannot search/); await client.close(); }); @@ -194,3 +196,103 @@ test("binary exits when its MCP client closes stdin", async () => { }); assert.equal(code, 0); }); + +const RESULTS = { + site: "bilibili", query: "rust", page: 1, + items: [ + { site: "bilibili", videoId: "BV1", url: "https://www.bilibili.com/video/BV1", title: "Rust 入门", author: "UP", duration: 754, views: 12345, uploadDate: "2026-01-02", description: "d" }, + { site: "bilibili", videoId: "BV2", url: "https://www.bilibili.com/video/BV2", title: "Live", author: "UP2", duration: 0, views: null, uploadDate: "", description: "" }, + ], +}; + +test("search_videos: defaults, one line per video, raw", async () => { + const s = await setup(); + const ext = fakeExtension(s.bridge.port, { onCall: (m) => (m.method === "search" ? { ok: true, data: { ...RESULTS, page: m.params.page } } : null) }); + await ext.opened; + await until(() => s.bridge.connected); + const r = await s.client.callTool({ name: "search_videos", arguments: { query: " rust " } }); + assert.equal(r.isError, undefined); + assert.equal(r.content[0].text, [ + 'Bilibili search "rust", page 1:', + "1. Rust 入门 | UP | 12:34 | 12345 views | 2026-01-02 | https://www.bilibili.com/video/BV1", + "2. Live | UP2 | https://www.bilibili.com/video/BV2", + ].join("\n")); + assert.deepEqual(ext.messages.find((m) => m.method).params, { site: "bilibili", query: "rust", page: 1 }); + const raw = await s.client.callTool({ name: "search_videos", arguments: { query: "rust", site: "youtube", page: 2, raw: true } }); + assert.deepEqual(JSON.parse(raw.content[0].text), { ...RESULTS, page: 2 }); + assert.deepEqual(ext.messages.filter((m) => m.method)[1].params, { site: "youtube", query: "rust", page: 2 }); + ext.ws.close(); + await s.close(); +}); + +test("search_videos: bad arguments never reach the extension", async () => { + const s = await setup(); + 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/]]) { + const r = await s.client.callTool({ name: "search_videos", arguments: args }); + assert.equal(r.isError, true); + assert.match(r.content[0].text, re); + } + assert.equal(ext.messages.filter((m) => m.method).length, 0); + 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(); + const ext = fakeExtension(s.bridge.port, { onCall: () => ({ ok: false, error: "YouTube 限流,请稍后再试", code: "THROTTLED" }) }); + await ext.opened; + await until(() => s.bridge.connected); + const r = await s.client.callTool({ name: "search_videos", arguments: { query: "x", site: "youtube" } }); + assert.equal(r.isError, true); + assert.match(r.content[0].text, /^THROTTLED: YouTube 限流,请稍后再试 Stop calling MoonDigest/); + ext.ws.close(); + await s.close(); +}); + +test("personal data: favorites, follows and history formatting", () => { + assert.equal(formatFavorites({ folders: [{ id: "toview", title: "稍后再看", count: 3 }, { id: "11", title: "Rust", count: 40 }] }), + "Folders (id · title · videos):\n- toview · 稍后再看 · 3\n- 11 · Rust · 40"); + const fav = formatFavorites({ folderId: "11", source: "local", total: 41, page: 1, hasMore: true, items: [ + { bvid: "BV1", url: "https://www.bilibili.com/video/BV1", title: "Rust 入门", author: "UP", duration: 125, favTime: 0, tags: ["教程", "Rust"], verdict: "keep", summary: "所有权讲透", note: "第二遍\n再看", kept: true }, + { bvid: "BV2", url: "https://www.bilibili.com/video/BV2", title: "Gone", author: "", duration: 0, favTime: 0, invalid: true }, + ] }); + assert.equal(fav, [ + "Folder 11. Page 1, 2 of 41 (MoonDigest's copy). More: page 2.", + "- [02:05] Rust 入门 · UP · https://www.bilibili.com/video/BV1 · kept · AI: keep · tags: 教程, Rust · summary: 所有权讲透 · note: 第二遍 再看", + "- [00:00] Gone · https://www.bilibili.com/video/BV2 · unavailable", + ].join("\n")); + const fol = formatFollows({ source: "bilibili", queryIgnored: true, total: 2, page: 1, hasMore: false, items: [{ mid: "7", name: "A", sign: "hi\nthere", groups: ["特别关注", "默认分组"] }] }); + assert.match(fol, /^Follows\. Page 1, 1 of 2\. Query ignored:/); + assert.match(fol, /\n- A \(mid 7\) · groups: 特别关注, 默认分组 · hi there$/); + const his = formatHistory({ cursor: "9:100:archive", items: [{ bvid: "BV1", url: "u", title: "T", author: "UP", viewAt: 100, progress: -1, duration: 60, kind: "archive" }, { url: "l", title: "Live", author: "", viewAt: 0, progress: 0, duration: 0, kind: "live" }] }); + const lines = his.split("\n"); + assert.equal(lines[0], 'History, newest first, 2 items. More: cursor "9:100:archive".'); + assert.match(lines[1], /^- \d{4}-\d{2}-\d{2} \d{2}:\d{2} · T · UP · watched finished · u$/); + assert.equal(lines[2], "- Live · live · l"); +}); + +test("personal data tools: params pass through, PERSONAL_DATA_OFF passes through", async () => { + const s = await setup(); + let off = false; + const ext = fakeExtension(s.bridge.port, { onCall: (m) => (off + ? { ok: false, code: "PERSONAL_DATA_OFF", error: "The user has not allowed it. Ask the user to turn it on." } + : { ok: true, data: m.method === "history" ? { items: [], cursor: "" } : { folders: [] } }) }); + await ext.opened; + await until(() => s.bridge.connected); + let r = await s.client.callTool({ name: "get_favorites", arguments: { folderId: "11", page: 2, query: "rust", raw: true } }); + assert.equal(r.content[0].text, '{"folders":[]}'); + r = await s.client.callTool({ name: "get_history", arguments: {} }); + assert.equal(r.content[0].text, "History, newest first, 0 items. No more."); + const calls = ext.messages.filter((m) => m.method).map(({ method, params }) => ({ method, params })); + assert.deepEqual(calls, [{ method: "favorites", params: { folderId: "11", page: 2, query: "rust" } }, { method: "history", params: {} }]); + off = true; + r = await s.client.callTool({ name: "get_follows", arguments: {} }); + assert.equal(r.isError, true); + assert.equal(r.content[0].text, "PERSONAL_DATA_OFF: The user has not allowed it. Ask the user to turn it on."); + ext.ws.close(); + await s.close(); +}); diff --git a/skills/moondigest/SKILL.md b/skills/moondigest/SKILL.md index d9f49be4..2befb363 100644 --- a/skills/moondigest/SKILL.md +++ b/skills/moondigest/SKILL.md @@ -10,13 +10,13 @@ The MoonDigest extension answers JS running in a Bilibili or YouTube video page. ## When to use it - The user links a video or asks about one. -- You are researching, looking for tutorials or comparing sources, and your search turns up Bilibili or YouTube videos. Do not judge them by title, thumbnail or view count. Read the most promising few, keep the ones that really cover what the user asked, and say what each one says with timestamps. MoonDigest cannot search: find the videos with your own search tools first. +- You are researching, looking for tutorials or comparing sources, and your search turns up Bilibili or YouTube videos. Do not judge them by title, thumbnail or view count. Read the most promising few, keep the ones that really cover what the user asked, and say what each one says with timestamps. The page messages cannot search: find the videos with your own search tools first, or use the MCP server's `search_videos` when it is configured. ## Before you start - The browser you drive (ego, Claude in Chrome, Playwright with the user's profile, …) has MoonDigest installed and enabled. - Bilibili shows subtitles (AI subtitles too) only to logged-in users. If `subtitle.needLogin` is `true`, ask the user to log in. Never type their password. -- If you have no browser but the `moondigest` MCP server is configured, use its `get_video` and `get_frame` tools instead of this skill. The MCP takes full video URLs only (`bilibili.com/video/…`, `youtube.com/watch?v=…`), not b23.tv or youtu.be short links. +- If you have no browser but the `moondigest` MCP server is configured, use its `search_videos`, `get_video` and `get_frame` tools instead of this skill. For the user's own Bilibili favorites, follows and watch history there is no page message: only the MCP's `get_favorites`, `get_follows` and `get_history` read them, and only after the user turns that on in MoonDigest's settings. The MCP takes full video URLs only (`bilibili.com/video/…`, `youtube.com/watch?v=…`), not b23.tv or youtu.be short links. ## 1. Open the page